The season stops being hardcoded, the chores run themselves, and Norsk Tipping drives the market #19

Merged
kristofferopsahl merged 30 commits from fix/gw2-window-and-provenance into main 2026-08-30 21:25:04 +02:00
Owner

24 commits, 28 Aug – 30 Aug. Large, so this opens with where to look and what is actually at stake rather than a list.

The app has been running this branch since 28 Aug. Nothing here is theoretical — every fix below was verified against the live page, and several were reported by the reader while using it.


The one thing that is a BET, and where to undo it

8342b4c makes Norsk Tipping the market source for goal-rate lambdas and goalscorer prices, replacing the Odds API consensus. Everything else in this branch is a fix or an addition; this is a judgement.

Measured before adopting it (306 player-fixture pairs, joined on FPL element id, same hour): NT is systematically shorter — pooled median implied-probability ratio 1.143, shorter on 81.7% of pairs — and quotes fewer players per fixture (33–39 v 35–47). What it adds is that it is free, unmetered, independent of five US books that likely share a feed, quotes anytime assists (a market the Odds API has never once returned), and on deadline day it was fresher than our own bootstrap: four NT names resolved to players at clubs not in the fixture, and all four were transfers completed that morning.

It has never been scored against a realised outcome. That is the honest status.

  • Revert seam: pipeline.market_file(gw, root) is the single place that decides which book wins. Flipping back is a small change, not a revert of this branch.
  • It gets measured automatically on Mon 31 Aug ~23:00 Oslo. scripts/score_goalscorer.py --gw 2 compares NT against the consensus against a base-rate control on log loss and Brier; the scheduler in a89fb53 runs it once GW2's last fixture ends.
  • One round will not settle it. The script says so itself: enough to catch a badly broken source, nowhere near enough to separate two close ones. Read Monday as "not broken", not as "better".

What else is in here

The season stopped being hardcoded. The largest theme, and it was found by a reader looking at the screen, repeatedly — not by tests.

  • 0f92969 — four odds paths still keyed to gw1_*.json after the window rolled. Every GW2 price uncorrected, market lambdas inert for the whole window, and every one failed silently because the file was PRESENT. Measured: GW2 went from 404 players FITTED to 404 MARKET.
  • e433040 — the fixtures grid opened on GW1 for the rest of the season.
  • 281c7a9 — nine GW1 literals in index.html; the tab read "Match predictions · GW1" above gameweek 3's fixtures.
  • 46d6f47cs_by_gw["1"] against a backend serving 3–8, so the clean-sheet "Ours" column was an em-dash for all twenty clubs. A wiring break rendered in the vocabulary of a data absence — the refusal machinery working perfectly is what made it look deliberate.

Each guard built for this family has been too narrow by exactly the axis the next instance used: the backend grep globs .py and only odds paths; the markup guard reads index.html for the string GW1; neither can see "1" used as a dict key or a route default.

The app follows the season by itself (9d9bd4d, a89fb53)

  • autorefresh.Refresher — bootstrap and fixtures every 300s, atomic, age published. Closes #17.
  • fpl_team.py — squad and bank read from FPL entry 6261 instead of derived. The reported symptom was a bank of 4.7 when the account held 0.0. Addresses most of #18.
  • chores.py — ingest/rebuild/score once a round ends; odds + PIT snapshot before each deadline. Decides from facts, not a clock. Verified end-to-end on live data. Addresses #13; the architecture decision is argued in that issue.

Reported by the reader, fixed here

  • 68a5f9d — the pitch put the C on one player while the armband panel beside it named another. /api/best-xi captained the top of a six-week sort; the armband is a weekly decision.
  • 68ea6d7 — "still no compare button", reported three times. There are two player cards rendering into the same DOM node; only one had it. Every test drove the tab the reader was not using.
  • 46d6f47Nott'm Forest would not open. HTML-escaping cannot protect a JS string literal in an event attribute: the parser decodes ' back to ' before the JS parser sees it.

Model and data

  • 2521bd7 — team results ingested; home_adv and rho pinned while ten matches refit around them (free, they ran to 0.569 and a sign flip on the bound).
  • 86ffbaa, 8342b4c — NT as a second book, then as the market; assists priced for the first time.
  • be383d9build_full takes a horizon; scripts/long_horizon.py ranks to GW28 with the tail discounted. Writes to data/analysis/, never the served cache.

Docs. CLAUDE.md +829 lines, including two corrections where the file was wrong in a way that had already misled a reader — most recently 9904afe, where the stated reason for leaving depth.py unwired described reassigning minutes, which that module explicitly refuses to do.


Verification

  • Full suite green, PYTEST_EXIT=0. ~250 new tests, each proved RED against the unfixed code first.
  • Mutation-checked where it matters. The scheduler's twelve guards were cut one at a time; ten went red immediately, one was double-guarded, and one test could not fail at all and was rewritten.
  • Live-verified. Every frontend fix was driven on the running app and read back, because a DOM check cannot tell "present" from "findable" — which is exactly how compare was reported missing three times while its tests passed.

Review guidance

Worth your attention, in order:

  1. backend/web/chores.py — new subsystem, runs unattended. The failure modes are in the module docstring.
  2. pipeline.market_file + 8342b4c — the bet, and its seam.
  3. backend/web/main.py best_xi — the captain now comes from rank_captaincy; it refuses rather than falling back.
  4. The rest is fixes with tests attached.

Not urgent, and not in here: #4, #7, #8, #11 need decisions rather than code. All four now have concrete answers in their issues, #4 with a measurement (blockers_for refuses 11 of 623, and the accuracy splits by position).

24 commits, 28 Aug – 30 Aug. Large, so this opens with where to look and what is actually at stake rather than a list. **The app has been running this branch since 28 Aug.** Nothing here is theoretical — every fix below was verified against the live page, and several were reported by the reader while using it. --- ## The one thing that is a BET, and where to undo it `8342b4c` makes **Norsk Tipping the market source** for goal-rate lambdas and goalscorer prices, replacing the Odds API consensus. Everything else in this branch is a fix or an addition; this is a judgement. Measured before adopting it (306 player-fixture pairs, joined on FPL element id, same hour): NT is systematically shorter — **pooled median implied-probability ratio 1.143, shorter on 81.7% of pairs** — and quotes fewer players per fixture (33–39 v 35–47). What it adds is that it is free, unmetered, independent of five US books that likely share a feed, quotes **anytime assists** (a market the Odds API has never once returned), and on deadline day it was **fresher than our own bootstrap**: four NT names resolved to players at clubs not in the fixture, and all four were transfers completed that morning. **It has never been scored against a realised outcome.** That is the honest status. - **Revert seam:** `pipeline.market_file(gw, root)` is the single place that decides which book wins. Flipping back is a small change, not a revert of this branch. - **It gets measured automatically on Mon 31 Aug ~23:00 Oslo.** `scripts/score_goalscorer.py --gw 2` compares NT against the consensus against a base-rate control on log loss and Brier; the scheduler in `a89fb53` runs it once GW2's last fixture ends. - **One round will not settle it.** The script says so itself: enough to catch a badly broken source, nowhere near enough to separate two close ones. Read Monday as "not broken", not as "better". --- ## What else is in here **The season stopped being hardcoded.** The largest theme, and it was found by a reader looking at the screen, repeatedly — not by tests. - `0f92969` — four odds paths still keyed to `gw1_*.json` after the window rolled. Every GW2 price uncorrected, market lambdas inert for the whole window, and **every one failed silently because the file was PRESENT**. Measured: GW2 went from 404 players `FITTED` to 404 `MARKET`. - `e433040` — the fixtures grid opened on GW1 for the rest of the season. - `281c7a9` — nine `GW1` literals in `index.html`; the tab read "Match predictions · GW1" above gameweek 3's fixtures. - `46d6f47` — `cs_by_gw["1"]` against a backend serving 3–8, so the clean-sheet "Ours" column was an em-dash for all twenty clubs. **A wiring break rendered in the vocabulary of a data absence** — the refusal machinery working perfectly is what made it look deliberate. Each guard built for this family has been too narrow by exactly the axis the next instance used: the backend grep globs `.py` and only odds paths; the markup guard reads `index.html` for the string `GW1`; neither can see `"1"` used as a dict key or a route default. **The app follows the season by itself** (`9d9bd4d`, `a89fb53`) - `autorefresh.Refresher` — bootstrap *and* fixtures every 300s, atomic, age published. Closes #17. - `fpl_team.py` — squad and bank read from FPL entry 6261 instead of derived. The reported symptom was a bank of 4.7 when the account held 0.0. Addresses most of #18. - `chores.py` — ingest/rebuild/score once a round ends; odds + PIT snapshot before each deadline. Decides from facts, not a clock. Verified end-to-end on live data. Addresses #13; the architecture decision is argued in that issue. **Reported by the reader, fixed here** - `68a5f9d` — the pitch put the C on one player while the armband panel beside it named another. `/api/best-xi` captained the top of a **six-week** sort; the armband is a weekly decision. - `68ea6d7` — "still no compare button", reported three times. There are **two** player cards rendering into the same DOM node; only one had it. Every test drove the tab the reader was not using. - `46d6f47` — `Nott'm Forest` would not open. HTML-escaping cannot protect a JS string literal in an event attribute: the parser decodes `'` back to `'` before the JS parser sees it. **Model and data** - `2521bd7` — team results ingested; `home_adv` and `rho` pinned while ten matches refit around them (free, they ran to 0.569 and a sign flip on the bound). - `86ffbaa`, `8342b4c` — NT as a second book, then as the market; assists priced for the first time. - `be383d9` — `build_full` takes a horizon; `scripts/long_horizon.py` ranks to GW28 with the tail discounted. Writes to `data/analysis/`, never the served cache. **Docs.** `CLAUDE.md` +829 lines, including two corrections where the file was wrong in a way that had already misled a reader — most recently `9904afe`, where the stated reason for leaving `depth.py` unwired described reassigning minutes, which that module explicitly refuses to do. --- ## Verification - **Full suite green**, `PYTEST_EXIT=0`. ~250 new tests, each proved RED against the unfixed code first. - **Mutation-checked where it matters.** The scheduler's twelve guards were cut one at a time; ten went red immediately, one was double-guarded, and **one test could not fail at all** and was rewritten. - **Live-verified.** Every frontend fix was driven on the running app and read back, because a DOM check cannot tell "present" from "findable" — which is exactly how compare was reported missing three times while its tests passed. ## Review guidance Worth your attention, in order: 1. `backend/web/chores.py` — new subsystem, runs unattended. The failure modes are in the module docstring. 2. `pipeline.market_file` + `8342b4c` — the bet, and its seam. 3. `backend/web/main.py` `best_xi` — the captain now comes from `rank_captaincy`; it refuses rather than falling back. 4. The rest is fixes with tests attached. **Not urgent, and not in here:** #4, #7, #8, #11 need decisions rather than code. All four now have concrete answers in their issues, #4 with a measurement (`blockers_for` refuses 11 of 623, and the accuracy splits by position).
Inputs, persisted before a change makes us want them. The GW1 props run is
already tracked for the same reason: re-acquiring a raw page costs whatever it
cost the first time, and you pay at the worst moment — right after finding a
parser bug, when the alternative is spending credits on work already owned.
The last time that lesson went unlearned it cost ~116 credits for nine records
that were already banked.

  data/odds/bulk_*.json          the 20-fixture vendor response, unfiltered
  data/odds/gw2_raw.json         h2h/totals/btts, GW2
  data/odds/gw2_market.json      Shin-devigged lambdas solved from the above
  data/odds/props/gw2_*/         anytime-goalscorer, one file per event
  data/current_2026_27/          rounds.json (player) + results.json (team)

`data/current_2026_27/` is what `fetch_current_season.py` writes and what the
in-season Dixon-Coles refit reads. It is an input to every rate, start_prob and
club rating in the window, so freezing it alongside the odds is what makes a
rebuild reproducible rather than merely repeatable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`build_strengths` had read `data/current_2026_27/results.json` since the
in-season Dixon-Coles refit was built — warm start, tuned ridge, four tests —
and NOTHING WROTE THAT FILE. `fetch_current_season.py` declared the `RESULTS`
constant, carried a full docstring for why it mattered, and never produced it.
Reader wired, producer absent; `_load_current` returns [] for a missing file,
so the fit took its pre-season path in silence while player rates updated
weekly beside it. Both artefacts are now written in one run, so their gameweek
coverage cannot drift apart.

Wiring it exposed a second defect in the refit. The ridge anchors attack and
defence to last season; `home_adv` and `rho` had no prior and were estimated
FREE on ten matches. On the real GW1 — 7 home wins, 1 draw, 2 away — home_adv
went 0.215 -> 0.569 (a home side's expected-goals multiplier x1.24 -> x1.77 on
every fixture in the window) and rho went -0.162 -> +0.200, a sign flip across
the full width of its range, finishing ON the bound. Both are LEAGUE-wide
quantities with no club-level evidence to average over, and a parameter that
runs to its bound on ten matches has not been estimated. Pinned to the 25/26
fitted values via home_bounds/rho_bounds, attack and defence re-estimated
around them. 0.215 sits inside the flat 0.126-0.25 plateau the home-advantage
sweep established; 0.569 is far outside anything ever scored.

Connecting the file also BYPASSED the promotion discount, which is the sharpest
instance of the absence pattern here. `apply_promotion_mapping` fills clubs
ABSENT from the fit, and absence stood in for "has no PL 25/26 history" — the
two came apart the first weekend of the season. The moment the promoted three
played a PL match they were no longer absent, the mapping applied to nothing,
and they were rated from the league mean plus one game: Hull attack +0.053 and
Ipswich +0.060, above-average attacking sides, Hull's defence better than
average, against the Championship mapping's -0.238/-0.202 and +0.26. The
warning naming the mapping vanished in the same instant, because it was emitted
by the path that no longer ran — estimate and substitute-flag lost together,
the worst available pairing. The mapping is now the PRIOR the refit warm-starts
from (`championship_priors_by_code`), which is what every other club already
gets from its own 25/26 fit.

A trigger keyed on a value being MISSING fires correctly right up until
something unrelated fills it in. Key on the condition you actually mean.

Also here, same cause:

  * `build_deps_only` unpacked `build_strengths` into `_pl_warns` and dropped
    it, so the one statement naming which season the ratings came from never
    reached the cache or the banner. `build_strengths` now returns
    (strengths, club_warnings, season_note) — three values because the
    promotion mapping REPLACES the club warnings while the note must survive
    that, and while they shared one list the caller could keep the note only by
    keeping stale warnings with it, so it kept neither.
  * `archive_only_warning` chose between its two messages with `e["finished"]`,
    so between the last GW1 kick-off and FPL verifying the round it would have
    said "no gameweek has finished" — the message naming NO ACTION — at exactly
    the moment running the fetcher was possible and overdue. `playable_gameweeks`
    moves to backend/data/season.py so the ingest and the warning share one
    definition of "played".

Club ratings do move, which is the point: on GW1 alone max |d attack| 0.19
(Brighton +0.19, Forest -0.14, Chelsea +0.13), and the promoted three gain a
real PL fit instead of only the Championship mapping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`brier_score`, `log_loss`, `calibration_curve` and `disagreement_tally` are all
built and tested in backend/web/calibration.py, and `accuracy_report` calls
NONE of them. Below MIN_RESOLVED (20) it writes a refusal on each metric; at or
above it, it does nothing — so the metrics keep value=None, keep the
AWAITING_RESULTS banner, and their `detail` goes EMPTY. The panel gets QUIETER
the moment it has enough data to say something. Ten fixtures are resolved, so
this is one gameweek from being visible.

Found because the self-dating gate in this file came due: it asserted
`resolved_fixtures == 0` with the note "a fixture has resolved — update this
test", and GW1 resolved it. The gate worked exactly as designed. Its
replacement asserts the invariant rather than the count, so it cannot expire
again, and `test_the_panel_has_no_scoring_branch_yet` pins the gap so it is
asserted rather than rediscovered — written to FAIL the moment scoring is
wired.

Scoring is NOT built here, deliberately. It needs stored predictions to compare
against, which is what data/pit/ snapshots hold; that is a real build, not a
wiring fix, and bolting it onto an ingest change is how two half-finished
things ship as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLAUDE.md's 2026-08-27 entry ends "grep for every other site that hardcoded the
same value before calling it done". The grep was not run. There were four more
sites, found the morning of the GW2 deadline, one day after the rule was
written:

  | site                            | what it read      | effect                                 |
  |---------------------------------|-------------------|----------------------------------------|
  | pipeline.py MARKET_SNAPSHOT     | gw1_market.json   | market lambdas inert for the whole window |
  | props_load.py H2H_RAW           | gw1_raw.json      | every GW2 price uncorrected, ~7% high  |
  | snapshot_pit.py LOCAL           | gw1_*.json        | GW2 snapshots froze GW1's market       |
  | props_vs_model.py               | both, --gw def. 1 | the apparatus, wrong the same way      |

Every one failed SILENTLY, and presence is what silenced them. All key on the
FIXTURE, and no two gameweeks share one, so each lookup missed every time and
took its documented quiet path — an uncorrected price, an unoverridden lambda.
`market_lambdas` warns when the file is MISSING; the file was there. Absence
rendered as well-formed presence, with the presence doing the rendering.

This was a CONSEQUENCE of the 2026-08-23 window fix, not a defect it missed.
While `GWS = (1, ..., 6)` was a constant, GW1 was in the window and
gw1_market.json matched. Rolling the window forward is what disconnected them:
a derivation replacing a constant can break sites that were correct only
because the constant made them agree by accident.

Measured, before and after a rebuild: GW2 went from 404 players FITTED to 404
MARKET (GW3-7 stay FITTED, correctly — only the next gameweek is ever priced).
Mean |d ev6| 0.139, 13 players over 0.5.

/api/matches is in here too, for the same root cause and because the loader
signatures below force it: it kept a PRIVATE COPY of the gameweek constant and
was never migrated, hardcoding 1 in six places — the league-average lambda
denominator, the kickoff lookup, the card loop, load_goalscorer, run_meta, and
the fixture each player's rate is scaled to. Six days after GW1 was played,
with GW2's deadline the following evening, the tab showed GW1's predictions and
GW1's scorer prices. User-reported, not caught by us. It now takes
`cache_gws(cache)[0]` and the response carries "gw" — a view that shows a
gameweek must NAME it, or a wrong one is indistinguishable from a right one.

And the sharpest of the three: A BANKED PROPS RUN SPANS MORE ROUNDS THAN THE
BOARD SHOWS. `fetch_props.py --gw 2` banks every event the vendor is quoting —
twenty fixtures, GW2 and GW3 interleaved — under a run named `gw2_...`.
`load_goalscorer` iterated the whole manifest and keyed by element id, so a
player priced in both rounds kept whichever came LAST. Hours before the
deadline the live board read

    Haaland  Man City  CRY A  68%  2 books

which is a GW3 price against a promoted side wearing the GW2 fixture's label —
wrong data with the right label, which this repo ranks above a null. It is now
filtered on the FPL fixture PAIR, a fact about the content; the run's NAME is a
fact about the request, and `FOUND` taught us which of those to trust. The
endpoint passes the fixtures rather than the loader guessing, and an empty set
REFUSES, since a board that silently empties is indistinguishable from a book
that quoted nobody.

The clue was on screen the whole time: the note under the table said "20
fixtures" for a ten-fixture gameweek. It counted the RUN while the table showed
the ROUND. It now counts what is served.

The grep is now `test_no_backend_module_hardcodes_a_gameweek_in_an_odds_path` —
the same instruction as something that fails. A note asking for a grep did not
survive one day.

snapshot_pit.py also stops skipping missing artefacts in silence (a bare
`continue` gave the same closing lines either way) and now freezes
data/current_2026_27/, which did not exist when LOCAL was written and is an
input like any other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docstring had reverted the fix that stopped it

Three defects in `_rank_scorers`, all of which put a name in a ranked position
that the number underneath it did not support.

A STALE DOCSTRING REVERTED A USER-REPORTED FIX. 4e8d092 (2026-08-15) stopped
unpriced players occupying ranked positions — a reader had found Munoz at "21",
the slot Mateta genuinely holds — and moved them to prose via
`_unpriced_but_ours`. It changed the code and left this docstring still saying
unpriced rows "follow the priced ones, rather than being dropped". Twelve days
later that paragraph was read as the spec, the code was changed to match it,
and the panel then ranked Munoz AND printed "No book priced Munoz — the
omission is the bookmakers' saying he is unlikely to feature" beneath him.

That is the [CORRECTED 2026-08-14] legend lesson exactly — "the wording would
have sent someone rebuilding a legend that was removed on purpose" — and it
did. When a fix changes behaviour, the prose describing that behaviour is part
of the fix. A cross-surface test now asserts the ranking and the "no book
priced" note can never name the same player.

MIN_START_FOR_BOARD = 0.10, a validity gate asked before any ranking question.
The board was carrying players with no plausible route to the pitch — Nicolas
Jackson at start_prob 0.00 and Gabriel Jesus at 0.047 both appeared, purely on
a good fixture. A player who will not be on the field cannot score in it, so
this is a question about validity, not about who is a good pick; the ranking
answers the second and had been asked to answer both. Deliberately LOW: set
higher it becomes a second hidden ranking on top of the book's, set to zero the
board carries long-term injuries FPL itself reports as
chance_of_playing_next_round = 0. `start_prob` is now carried through the
endpoint so the gate has something to read — without it the gate reads None for
every row and silently keeps everyone, which is the shape this repo keeps
hitting.

ONE BOOK IS NOT A MARKET. A single-book price led the board as though it
carried the same weight as a six-book consensus. It is still shown; it no
longer leads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`lam_source` is set in engine.py, listed in REQUIRED_GW_FIELDS and written to
every row of every cache. It was read by NOTHING. While four modules pointed at
gw1_market.json after the window rolled to GW2-7, it said FITTED on all 404
rows for five days, in an artefact nobody opens. That is `is_prior` one step
on: not a flag nothing branches on, but a correct flag with no reader.

`lambda_provenance` reads the cache's own rows, /api/status serves it as
`lam_sources`, and the header now prints, beside the build time:

    projections built 28.8.2026, 09:13:46 · goal rates GW2 market odds · GW3–7 our fit

Three decisions:

  * A POSITIVE STATEMENT, not a warning. FITTED is the correct and permanent
    state of every gameweek after the next one, since only the imminent round
    is ever priced. A detector on it would be on forever, and a warning that is
    always on is one you stop reading — then it fails silently on the day it
    matters. Same reasoning that made `archive_only_warning` positive.
  * COUNTS FIXTURES, and never collapses a mixed gameweek to one label.
    Counting rows would report the size of a lineup wearing a coverage number,
    which is the props panel's "20 fixtures" note again. A gameweek with some
    fixtures priced and some not has never occurred, so it is forced with a
    synthetic fixture rather than waited for.
  * SOURCES AS FOUND. An allowlist of {MARKET, FITTED} would silently drop a
    third value the day one is added, in the surface built to prevent that.

`fixture` joins REQUIRED_GW_FIELDS because it was already mandatory: four
modules index r["fixture"] with no fallback, so a cache without it did not
degrade, it raised KeyError somewhere downstream of the check written to
prevent exactly that. One test cache omitted it and lam_source both, a shape
production cannot produce.

A NOTE ON THE TESTS, because the prove-RED pass caught the guard rather than
the code. Eight mutations, seven RED — and deleting the line that concatenates
the rendered string into textContent left the source-grep boundary test GREEN.
The name was still mentioned, the element still named beside it, and the value
computed and thrown away: `_pl_warns` again, inside the test written to prevent
it. A grep over source proves a name is MENTIONED; only the live page proves it
was DISPLAYED. Both are kept and they fail to different cuts — the grep catches
a rename across the language boundary the page cannot see, the page catches a
computed-and-discarded value the grep cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was nine rules appended over five design passes; 15 of its 28 declarations
never bound. Consolidated to one rule at the end of the file with its
breakpoint override directly beneath it.

Reading the cascade forward from the first match had already produced two wrong
conclusions in one session, and this pass found that the LAST matching rule is
not the binding one either. `#tab-squad .pitch` (specificity 1,1,0) beats every
bare `.pitch` rule from anywhere in the file, so `padding` is set 1,500 lines
from where the other pitch declarations live. Specificity is resolved before
position; position only breaks ties. Two things went wrong from assuming
otherwise: a dead-declaration count that named the wrong winners, and a
prove-RED test that mutated `.pitch { padding-top }` and correctly detected
NOTHING. `CSS.getMatchedStylesForNode` over CDP returns the matched rules in
cascade order, which is the only answer that is not a reading.

MOVING A DECLARATION LATER IS NOT FREE. The `@media (max-width: 1180px) {
.pitch { max-width: 520px } }` override sat 90 lines ABOVE where the merged
rule landed. Same specificity, so position decides — consolidating without
moving it would have widened the pitch on every screen under 1180px and looked
perfect at the 1600px anyone would have checked. That is why the gate sweeps
twelve widths.

A declaration measured dead still earns a comment, not a delete: `.pitch {
padding }` loses to `#tab-squad .pitch` everywhere the pitch mounts, and is
kept and labelled, because "does nothing HERE" is not "does nothing" — it is
the value the pitch would fall back to if ever mounted elsewhere.

The gate renders the live stylesheet and a frozen tests/data/style.baseline.css
side by side and compares every element's FULL computed style across four tabs
and twelve widths. Re-baseline deliberately when a design change is intended;
never to make it pass.

A RENDER GATE PROVES NO-CHANGE ONLY IN WHAT IT CAN OBSERVE, and the
unobservable set is named: it drives settled states, so it cannot see
@keyframes (values BETWEEN settled states), @font-face, or print/hover/prefers-*
conditions the harness never enters. A deletion inside `@keyframes
loading-pulse` passed the sweep cleanly — the stubbed page never shows a
loading skeleton — and changed the pulse from .35->.8 to 1->.8.
`test_at_rules_the_render_gate_cannot_see_are_unchanged` compares those as
TEXT. Text equality is too strict everywhere else and exactly right where the
browser cannot be asked.

Two process lessons from the same hour, both more general than CSS:

  * Never validate a transformer's output with the transformer's own parser.
    "2,791 winning declarations before and after, none lost, none changed" was
    computed with the broken scanner and faithfully confirmed the tool agreed
    with itself while the file was wrong.
  * Assert the invariant you CLAIMED, not the ones that are easy. A later pass
    reported zero empty rules, balanced braces and a green gate while silently
    dropping a comment. Only a before/after comment COUNT caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each entry carries its date because the ones here go stale fast — this file
already holds three [CORRECTED] blocks where a stale paragraph sent a day of
work at the wrong problem, and two more of those happened this week.

New rules earned this week, each by the bug it now prevents:

  * A trigger keyed on a value being MISSING fires correctly right up until
    something unrelated fills it in. (The promotion discount was bypassed the
    moment the promoted three played a PL match and stopped being absent.)
  * Fixing a defect in one location is not fixing the defect — and the grep
    that rule asked for went unrun for one day, with four more sites waiting.
  * Specificity is resolved before position; position only breaks ties. The
    LAST matching rule is not the binding one.
  * A render gate proves no-change only in what it can OBSERVE, and the
    unobservable set must be named.
  * Never validate a transformer's output with the transformer's own parser.
  * A grep over source proves a name is MENTIONED; only the live page proves it
    was DISPLAYED. Recorded under prove-RED-for-wiring, where the crude
    `in runner_src` assertion is endorsed — it stops one step short of the
    screen, and was caught by its own mutation pass.

Corrections:

  * lam_source is carried in REQUIRED_GW_FIELDS, not REQUIRED_PLAYER_FIELDS as
    this file said. It is a per-gameweek field and the wrong constant sends a
    reader to the wrong file.
  * "Surfacing it is open work" is now done, with the three design decisions
    recorded so the next reader does not re-litigate them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reader's report: the top 20 is full of players who are "almost certainly not
gonna play", making the panel useless. Correct, and the cause is not where it
looked. Measured on run gw2_20260827T185145Z, all 422 priced players joined to
GW1's realised minutes, none dropped.

TWO SIGNALS WERE ALREADY IN THE DATA AND UNREAD.

1. WHETHER A BOOK QUOTES HIM AT ALL. An anytime-goalscorer price is
   CONDITIONAL — the bet voids on non-appearance — so it says nothing about
   whether he plays. Whether the books covering his match bothered to quote him
   says it loudly:

       quoted by <80% of the books covering his match   133   2% started   2.8 min
       quoted by >=80%                                  289  65% started  57.6 min

   `n_books` was computed and read only for a `confidence` label and a
   consensus/thin split at MIN_BOOKS = 2 — which is why three-book rows on a
   five-book fixture ranked beside six-book consensus. A SHARE, not a count:
   every fixture in this run drew 5 or 6 books, so a raw count conflates "two
   books like him" with "the fixture only drew two books", the
   coverage-is-a-timing-artefact trap already recorded in CLAUDE.md. 0.80
   rather than 1.00, chosen against pre-registered alternatives on the same
   data: unanimity cuts 185 players and takes 11 GW1 starters with them,
   against 2 here, and buys only two fewer dead rows in the top 20.

2. FPL'S OWN FLAG, WHICH THE BOOK GATE ALONE MADE WORSE. Applying (1) promoted
   Osula to rank 19 — quoted by SIX of six books, with `status=i,
   chance_of_playing_next_round=0, "Foot injury - Unknown return date"` in
   FPL's feed and `availability_by_gw["2"] == 0.0` sitting in the cache.

   Full coverage is what a bookmaker leaves standing when it has not repriced,
   so the availability signal in `n_books` is stale EXACTLY where it looks most
   confident. That is why this is a third witness and not a tuning of the
   second. `availability_by_gw` has been computed in absence.py and written to
   every cached player since injuries were modelled, and this surface never
   read it — the third field found today that is correct, cached and unread,
   after `lam_source` and `n_books`. 61 players in the pool carry it as 0 for
   GW2.

   Only a HARD ZERO excludes. A 25% or 75% doubt is a rotation risk, and a
   rotation risk belongs on a board about who might score this weekend.

THREE INDEPENDENT WITNESSES, deliberately kept apart rather than blended into
one score: our archive-derived minutes model, the bookmakers' coverage, and the
competition's own feed. They catch different players — the model catches
Rashford (five books, start_prob 0.00), the share catches G.Jesus and Zirkzee
(0.12 and 0.11, three of five books, ranked 10th and 15th), the flag catches
Osula. Reconciling them would delete the disagreement that makes the set
useful.

Live GW2 board, before and after:

    before   8/20 did not start GW1   3 with start_prob < 0.30
    after    6/20                     1

The six that remain are all `status=a` — available rotation risks, which is a
judgement about what the board is FOR, not a data fault, and is left alone.

A NOTE ON THE TESTS. The first prove-RED pass left three mutations GREEN: two
weak assertions, and — again — a missing wiring test, where deleting the field
from props_load left every gate returning True and the board silently
reverting. Both wiring tests are now present and both go RED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An eight-day-old "cheapest open lead" closed by a probe that took minutes and
cost nothing. The entry said the question was still unknown; it is answered.

`POST content/get` with `{"type":"event","id":...}` returns all 144 markets for
a fixture in one ~600 KB response via plain urllib — no browser, no Firecrawl,
no credits. `{"type":"marketGroup","id":"89304.1"}` enumerates every PL event
id. The market is `Scorer mål`; prices are FRACTIONAL, decimal =
currentpriceup/currentpricedown + 1. Two corrections to the recorded next step:
live odds arrive over `/content/subscribe`, whose response is only
`{"version":N}` and whose payload never reaches a main-thread fetch patch, so
`/get` is the call to build on; and matching the league node by substring picks
`England Premier League Vinnerspill` (outrights, 0 events) rather than 66772.1.

VERDICT: ADDITIVE, NOT A REPLACEMENT. Measured against the banked GW2 run,
same market, same hour, all 9 pre-match fixtures, 306 pairs: NT is the shorter
price on 81.7%, pooled median implied ratio 1.143, quartiles 1.043/1.143/1.250.
It quoted fewer players than the five-book consensus in all 9 fixtures (333 v
380), charges more margin, and being one book cannot produce the coverage-SHARE
witness the goalscorer-board fix depends on. What it adds: free, unmetered,
independent, re-pullable on a loop, and it quotes the imminent round only — all
9 GW2 fixtures carried selections, all 8 GW3 fixtures carried zero.

THE STRONGEST ARGUMENT TURNED OUT NOT TO BE THE PRICE.

Four NT names resolved to a player at a club not in the fixture, which read as
bad vendor data. Every one was a transfer that completed that same day, after
the 07:11 snapshot the morning's work ran on — Marmoush to Spurs, Nico González
to Newcastle, Delap to Forest, Pinnock to Coventry, plus Goretzka arriving at
Villa as a new element. NT had all five right; the banked Odds API run had
priced none of them. A free source was more current than both the metered one
and our own bootstrap, on deadline day.

Two things kept from that. A wrong-club name is a QUESTION, not a verdict — the
refusal was correct and the fix was upstream, which is "when a detector fires
often, suspect the question" pointed at squads instead of search terms. And the
point-in-time snapshot was 12 hours and 5 transfers stale at the deadline, so
snapshot LATE: an early snapshot is not the state the squad was locked in.

TWO MEASUREMENT NOTES, both instances of rules already in this file.

Join on the FPL element id, not name-to-name. A first pass matched book names to
NT names directly and found 271 pairs; the books write "Francisco Evanilson de
Lima" where NT writes "Evanilson", so it undercounted agreement AND inflated the
book side's coverage with one player under two spellings. The resolver this
needed was already in the repo.

The first fixture measured alone gave 1.190 against a pooled 1.143 — near the
top of the range. "A fixture only tests the magnitudes it contains" applies to
bookmakers too, and nine cost no more than one once the pull was banked.

Raw pull and analysis stay in scratchpad, deliberately NOT in data/odds/props/:
an unrecognised run in that directory is exactly what load_goalscorer iterates,
which is the GW2/GW3 leak fixed in 726c73b. Nothing in the pipeline is wired to
NT — this commit records a finding, it does not add a source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
Fetcher and loader only. Nothing renders it yet, deliberately: NT's price has
never been scored against a realised outcome, and a column we cannot justify is
not worth putting on screen. GW2 resolves this weekend and is the first chance
to test it.

WHY IT IS ADDITIVE AND NOT A REPLACEMENT. Measured against the banked GW2 run,
same market, same hour, all 9 pre-match fixtures, joined on FPL element id:
306 pairs, NT the shorter price on 81.7%, pooled median implied ratio 1.143,
quartiles 1.043/1.143/1.250. It quotes fewer players than the five-book
consensus in all 9 fixtures (333 v 380). As a price it is worse, and being one
book it cannot produce the coverage-SHARE witness the goalscorer board depends
on. What it adds is that it is free, unmetered, independent of five US books
that likely share a feed, and re-pullable on a loop.

AND IT IS FRESHER, WHICH IS THE REAL ARGUMENT. On the GW2 deadline NT carried
five squad changes our own 07:11 bootstrap snapshot did not, and that the
banked Odds API run had not priced at all: Marmoush to Spurs, Nico González to
Newcastle, Delap to Forest, Pinnock to Coventry, Goretzka new at Villa. It
surfaced as four NT names resolving to a player at the wrong club — a detector
firing correctly on a stale input, not vendor error. `nt_load` reports those as
`disagreements` and withholds the price: we will not attribute a price to a
fixture we believe the player is not in, and we will not throw away a book
disagreeing with us about where he plays.

THREE THINGS THE PARSE HAS TO GET RIGHT, each with its own test:

  * Prices are FRACTIONAL. `up/down + 1`. Reading `up` alone makes evens 1.00 —
    below the floor a decimal price can cross, and still a plausible number in
    a column of plausible numbers.
  * `Scorer mål` sits beside `Scorer 2 eller flere mål` and `Kampens 1.
    målscorer`, all pricing the same players. Substring takes three, prefix
    takes two; the market name is matched EXACTLY and the test fails on Isak's
    value, not on a count.
  * `CONTENT_NOT_FOUND` arrives as a well-formed HTTP 200, so refusal is
    computed from the content. The injected transport returns the same envelope
    the network does, so the unwrap and that check run identically under test —
    a transport handing back pre-unwrapped data would erase both code paths
    from every test using it.

An event with no anytime market is a STATE, not a gap: NT opens the market for
the imminent round only, all 9 GW2 fixtures priced and all 8 GW3 at zero.

TWO CHANGES OUTSIDE THE NEW FILES.

`check_club_map`'s staleness rule keyed on `set(CLUB_ALIAS) - set(feed_names)`,
so an alias was "stale" whenever THIS feed happened to spell a club FPL's way.
One label over two causes — harmless with one feed, wrong the moment a second
exists, because NT writes "Coventry" where the Odds API writes "Coventry City"
and every alias added for either would be reported stale to the other. Now keyed
on the alias TARGET not being an FPL club, which is what its docstring always
said.

Four overrides added, all NT-only: two first-name variants (Andy/Andrew,
Dan/Daniel) and two transliterations (Kostoulas b-for-p, Yarmolyuk y-for-i). No
rule should bridge either — the first pair means matching on surname alone, the
second means fuzzy surnames. Each read off pit/20260828T193505Z and resolved
against the two clubs of the fixture NT quoted it in, exactly one candidate
apiece. NT's other 333 GW2 names resolved on the existing rules.

RAW IS GITIGNORED, MANIFESTS ARE NOT. A run is ~6.8MB and NT is free and
re-pullable, so these accumulate in a way the metered props runs never can —
one pull is nine times the entire committed props history. Same call as
data/pit/: the artefact lives on disk, where "persist inputs before a change
makes you want them" needs it, not in history. The manifest is small and is the
provenance record, so it stays tracked.

Banked under data/odds/norsk_tipping/, never data/odds/props/, because
`load_goalscorer` iterates that directory and would read an NT run as an Odds
API one. Asserted structurally rather than by convention.

Verified live: fetcher banked 17 events (9 priced, 8 at zero); loader read them
back to 337 GW2 players, 0 disagreements against the fresh snapshot. Mutation
pass 14/14 RED, including a cut fetcher-to-loader manifest contract and banking
into the props directory. Full suite 1211 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
User-reported, hours after the GW2 deadline: "why are you now showing gameweek
1". The grid was rendering GW1-6 — five rounds played, one already locked —
while /api/players, /api/status and /api/matches had all correctly rolled to
GW3-8. Right data, wrong weeks, every session.

    frontend/app.js   let FX_START = 1;   // first gameweek in the window

A module-level literal, moved only by `stepFixtureWindow()` on a ‹ › click.
Nothing ever derived it, so the opening window stayed on gameweek 1 for the
rest of the season unless the user paged past it by hand. It now comes from
`projectionGws()` — the per-player rows the numbers are summed from, the same
source `windowLabel()` already used, so the grid cannot disagree with the
totals printed beside it.

THE FIFTH SITE OF THE SAME DEFECT, and the first the grep could not see.
`GWS = (1, ..., 6)` was a constant until 08-23; `/api/matches` kept a private
copy until 08-27; four odds paths kept theirs until 08-28. The rule written
after the third — "fixing a defect in one location is not fixing the defect" —
produced `test_no_backend_module_hardcodes_a_gameweek_in_an_odds_path`, which
is a real gate and passed honestly while this sat one directory over. A
mechanical check is only as wide as its glob, and the glob is part of the
assertion.

WHY NO RENDER TEST CAUGHT IT. `_payloads()` gives players `gws` of range(1, 7)
and fixtures of range(1, 12), so the stub's projection window started at
gameweek 1 and `FX_START = 1` was indistinguishable from a correct derivation.
A symmetric fixture, already on this repo's normaliser list — this is that entry
with a gameweek in it. The new payload is deliberately asymmetric, players on
GW3-8 against fixtures on GW1-11, which is the live shape and the only one that
can tell the two apart.

THE FIRST FIX HAD A RACE. `loadFixtures()` and the players fetch are
independent awaits, so the grid can render with FIXTURES present and PLAYERS
still empty. Latching the derived start on that render caches the fallback 1 for
the session — the same defect back, intermittently, which is strictly worse than
the constant it replaced. The value is now latched only once there is a window
to derive from, and the test drives that order directly rather than racing the
network. `stepFixtureWindow` derives it too, so the pager cannot depend on a
render having happened first.

A DERIVED DEFAULT IS NOT A FLOOR. The ‹ pager still walks back into played
gameweeks; they stopped being where the grid OPENS, not where it can go —
content folds, it does not vanish, which is the responsive-layout rule pointed
at time instead of width.

Read off the rendered column headers, not off FX_START: a grep proving the
value is derived somewhere would still pass if the render never used it.
Mutation pass 4/4 RED, including the derivation computed and then discarded by
the filter. Full suite 1211 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
Asked for directly: put the standalone NT board into the site, make NT the
market behind match predictions and anytime goalscorer, and add assists if the
data exists.

ASSISTS EXIST, AND NOTHING ELSE HAS THEM. `Spiller gjør en assist`, 315 players
across all 9 GW2 fixtures. The Odds API has returned ZERO books for assists on
every probe — 2026-07-23 and again 08-15 — so this is a quantity the metered
source cannot supply at any price, and an assist is three points. Two traps the
goalscorer market does not have: selections are named "<player> Ja", stripped at
the parse so nothing downstream has to remember it, and two more decoys price
the same players (Isak 4.80 here against 32.00 and 10.00 there).

MATCH PREDICTIONS NOW COME FROM NT. Its three-way, over/under 2.5 and
both-teams-to-score go through the SAME `solve_lambdas` the consensus does;
only the extraction differs. GW3 went from 0 to 8 of 10 fixtures carrying a
market-anchored goal rate where every one was FITTED, because odds are bought
for the imminent round only and `gw3_market.json` has never existed. Measured
over a rebuild: mean |d EV| 0.029, 31 player-gameweeks past 0.5.

`MARKET_NT` is a DIFFERENT LABEL from `MARKET`, deliberately. The consensus is a
median across 19 books; NT is one state monopoly charging ~14% more margin on
the goalscorer market. One label over two origins is the defect this repo names
first, and the header's provenance line would have reported a coverage the
numbers do not have. The engine stays ignorant of bookmakers — it reads the
source off the map the caller hands it, which is what its own comment already
promised; a pair still means the consensus, so every cached row keeps its
meaning.

ON THE BOARD, NT SETS THE PRICE AND THE CONSENSUS KEEPS THE WITNESS. The
play-filter reads `n_books / n_books_fixture` — the share of books covering a
fixture that bothered to quote a player — and that gate caught G.Jesus and
Zirkzee sitting top-15 on start probabilities of 0.12 and 0.11. NT is one book,
so its own share is always 1.0 and the witness would have vanished in the swap.
Two labelled fields from two sources, not one value reconciled from both.

THREE DEFECTS FOUND ON THE WAY, none of them in the new code:

  * `pipeline._ODDS_ALIAS` was a THIRD private club-name table and had already
    drifted — carrying `Wolverhampton Wanderers`, not in this division, while
    missing `Coventry`, which is. It cost three of ten GW3 fixtures their
    market lambdas. The one-home test written after the last drift only ever
    looked at main.py; a one-home rule needs one assertion per home.
  * `market_lambdas` and `main.market_path` each picked their own market file
    for a few minutes, which would have put the projection on NT while the
    card's market column read the consensus — two books under one heading.
    `market_file` is now the only place that decides.
  * The wiring test for `merge_market` matched `_discard = merge_market(...)`
    and stayed GREEN with the result thrown away. `_pl_warns` again, inside the
    test written to prevent it. It asserts the assignment now, and what it
    still cannot see is named in its docstring.

THE FRONTEND CLAIMED ASSISTS HAD NO MARKET. `app.js` said so and built its
second column on it — true of the paid feed, and about to become the sentence
that reverts this fix, exactly as a stale docstring did on 08-28. Replaced, and
tokenised so the comment explaining it does not read as the claim returning.

Three of this repo's own design guards failed on the first pass and all three
were right: a hex literal outside tokens.css (which also invented a token name
that does not exist, so the fallback would have painted forever while looking
themed), a margin printed to a tenth of a percent, and a ramp test anchored on
header copy my conditional header changed. The first two were fixed at source;
the third was re-anchored on the element, so it fails when the ramp changes
rather than when wording does.

Scoring is built and cannot run yet: `scripts/score_goalscorer.py` refuses a
partial round, and GW2 has 0 of 10 fixtures finished. It pairs the rows, scores
only appearances (the bet voids otherwise), and carries the base rate as a
named control. Our own model is deliberately NOT scored there — `p_score_cond`
is computed inline in `/api/matches`, and reproducing that formula in a
diagnostic is the measurement-apparatus failure this repo has already paid for.

Verified on the rendered DOM, not by grep: 8 tests drive the real Matches tab.
Mutation pass 14/14 on the loader and market chain. Full suite 1282 passed,
0 failed, 0 skipped, pytest exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
Three things the reader asked for, and one reason none of them was visible.

THE ROUND BEING PLAYED IS NOT THE ROUND YOU CAN ACT ON. Reported as "why is
not gameweek 2 showing up in the matches tab": GW2's deadline had passed, so
`projection_window` had correctly rolled to GW3-8 and this tab followed it,
showing fixtures a week away while nine GW2 matches were under way.

Both windows are right and the app had only one. The window answers "which
gameweeks can I still CHANGE the squad for" and is keyed on the DEADLINE —
everything summed over it depends on excluding locked rounds, and widening it
is the 2026-08-23 defect where the optimiser partly maximised points already
decided. `gameweek_in_play` answers "what is happening on the pitch" and is
keyed on the FIXTURES. It lives in season.py beside `playable_gameweeks` and
reuses that function's over-predicate, so the two cannot drift about when a
round has ended. This is the one place in this repo where `finished` is the
right field to read, because here the question really is "is it still being
played" — and `finished_provisional` counts as over, since FPL reported every
GW1 fixture unfinished for two days after it was played.

No cache change was needed, which was checked rather than assumed: player rates
and expected minutes are player-level, the fixture scale comes from
`fixtures_grid` (which iterates every fixture, not the window), and
`availability_by_gw` spans all 38 gameweeks — so the FPL-availability gate on
the goalscorer board stays live for a locked round instead of silently going
inert.

THE STANDALONE BOARD, PORTED INTO THIS APP'S COLOURS. The three-way result now
sits on each card with the chance large and the decimal price beneath, and
pressing a card opens that fixture's own anytime scorers and anytime assists.

THE PALETTE IS NOT TRANSLATED HUE-FOR-HUE. tokens.css opens by saying this app
encodes by LUMINANCE, not hue; the artefact was warm-light with an accent per
outcome. Giving home/draw/away three hues here would also spend three colour
channels on team identity when the quantity on screen is LIKELIHOOD, and would
collide with the fixture ramp already on the same card. So the favourite
carries --accent and the other two recede. One channel, one meaning.

Players are built on demand, not with every card — twenty fixtures by forty
players is 800 rows nobody asked to see — and matched to a fixture on the CLUBS
rather than on a rendered label, which would break the first time either side
spelled a club differently. `assisters` had to stop being truncated to 20
server-side for that to be possible from the client; the panel still shows
twenty.

AND THE REASON NONE OF IT WAS ON SCREEN. index.html pins every asset at `?v=N`
and that N had not moved since before this work started, so the browser served
a cached app.js and style.css. Nothing was broken; the feature had been
reported as visible three times against a page that could not have shown it.
That is the disconnected-mechanism failure at the last possible step — written,
wired, tested, and rendered correctly in a harness that loads the files fresh
every time, while the one reader who mattered got the old ones.
`tests/web/test_asset_version.py` hashes the four versioned assets, so changing
one without bumping `?v=` now fails with the digest to paste in.

TWO DEFECTS THE SCREENSHOT AND THE TESTS CAUGHT, NOT THE CODE:

  * The card never served the decimal odds at all — `hdaBlock` read an
    undefined `n.odds` — so the prices the reader specifically liked would not
    have rendered.
  * `ntLines` used `LG.pct`, which returns {value, text} for the league-table
    renderer, and printed "[object Object]" for all three goal lines. The
    render test asserted the CAPTION "Over 2.5" was present and never looked at
    the number beside it. Checking the caption of a value is not checking the
    value; the assertions now demand 58%, 56% and 7% and reject
    "[object Object]" outright.

The CSS baseline was re-taken deliberately, not to make a failure pass: exactly
one at-rule was added (`prefers-reduced-motion` for the new chevron, the
stylesheet's first), nothing changed or was removed, and the computed-style
sweep across four tabs and twelve widths passed unchanged — so the new
selectors alter no existing element.

Mutation pass 3/3 on the in-play wiring, including reverting the tab to
`cache_gws(cache)[0]`. Full suite 1305 passed, 0 failed, 0 skipped,
pytest exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
Reported with a screenshot: "Match predictions · GW1" printed above gameweek
3's fixtures, with "no prices" on every card and a note saying no bookmaker
quotes assists. Three separate defects behind one screen, and an hour of
misdiagnosis before the real one.

NINE `GW1` LITERALS IN index.html, AND EVERY GREP HAD BEEN GLOBBED TO .py AND
.js. Three panel headings on Matches, two on Fixtures, a clean-sheet hint, an
accuracy kicker, and two paragraphs asserting that GW1 runs on bookmaker prices
and that no bookmaker quotes an assist market. The sixth site of this defect
after `GWS`, `/api/matches`, four odds paths and `FX_START` — and the first in
markup, so `test_no_backend_module_hardcodes_a_gameweek_in_an_odds_path` and
every fix before it passed honestly while the label a reader actually looks at
was wrong. A mechanical check is only as wide as its glob, and the glob is part
of the assertion.

A LABEL IS A CLAIM ABOUT THE DATA. Written by hand beside data that moves it is
wrong on a schedule, and worse than no label: a reader seeing GW1 over gameweek
3 cannot tell which half is broken. The headings are spans filled from what was
served, both prose hints are written by renderMatches from the gameweek AND the
book in use, and /api/market now states its own gameweek and source because the
Fixtures panels had the same literals. The new guard strips HTML comments first,
so the prose explaining the removed literals is not read as their return.

A STARTUP SNAPSHOT GOES STALE AND CORRECT CODE READING IT IS STILL WRONG.
`gameweek_in_play` keyed on `started`/`finished`, which live in
`_deps["fixtures_raw"]` — fetched ONCE, when the server starts. The flags flip
through the weekend and the snapshot does not, so a server started before the
first kickoff reported no round in play for the whole round. Measured after the
fix: live fixtures -> 2, and the same fixtures with every flag forced False ->
2; before it the second returned None. `kickoff_time` is fixed when a fixture
is scheduled, so it is the one field a stale snapshot cannot get wrong; the
flags still SHARPEN it, since a round every fixture reports finished is over
even if the last kickoff was recent.

The old tests could not have caught that, and that is the part worth keeping:
every one of them wrote `started: True` by hand. A fixture that cannot be stale
cannot catch a staleness bug — the fabricated-inputs normaliser pointed at
time. The new ones build fixtures from wall-clock offsets.

THE SCORER PAYLOAD WAS CAPPED AT 20. `_rank_scorers` cut to SCORER_ROWS, which
is right for the panel and wrong once a match card shows its own fixture:
twenty rows over ten fixtures is two or three a card, against the thirty-eight
the book quoted. Live GW2 now serves 264 scorer rows, 21-32 per fixture, and
the panel still shows twenty — the cut moved to the caller that wants it. The
existing test pinned `len(out) == SCORER_ROWS`; it is re-anchored on the
property it is named for, that a player the book rates and we do not survives
into the visible twenty.

THE SCREEN NAMED NEITHER BOOK. Two sources price this tab and "Norsk Tipping"
appeared only in an aria-label, a title tooltip and source comments while it
drove every number on the page. Cards now read NORSK TIPPING · IN USE with
"Result odds · Norsk Tipping" beneath the three-way, and both player panels
head their column with the book the rows actually came from — cut from each
row's own `source` field, never written per surface.

ONE ORDER, BY KICKOFF, at the reader's request. The disagreement ordering is
removed rather than demoted to a button: two orders let the same ten fixtures
appear in two sequences with nothing on the card saying which. The disagreement
COUNT stays in the header, being a fact about the round. Asserted as an ABSENCE
so the state it must not return to is the half-removed one — and that
assertion immediately caught orphaned CSS, the button styling having outlived
its markup.

THE WEEKLY CHORE IS A SCRIPT NOW. `scripts/after_gameweek.py --gw N` ingests
the finished round, rebuilds the cache, then scores — in that order, because
ingesting without rebuilding changes nothing a reader can see and scoring
before the rebuild reports numbers against code the cache predates. It refuses
a round still being played and stops before the rebuild if the ingest fails, a
rebuild on a failed ingest being indistinguishable from success. It was first
scheduled inside a Claude session, which dies with the session: a chore that
exists only as an intention is the run-scope defect wearing a calendar.

`score_goalscorer.py` also stopped requiring `finished`, which would have made
a Monday-night run refuse for days — FPL reported every GW1 fixture unfinished
for two days with only the provisional flag set.

AND THE ONE THAT COST THE HOUR: the server had been running since 28.08 09:14,
holding code from before all of this and a fixtures snapshot from before any
GW2 match kicked off. Both symptoms, one fact, and the reader was asked to
restart three times while it was diagnosed as caching, then stale deps, then a
stale snapshot. Check what is RUNNING before theorising about what is written —
the process list is one command and it is evidence. This app also serves
/static/* through a no-cache middleware, so "hard-refresh" was never the fix
either.

Verified on the live server after replacing the process: gw 2, 10 cards, 9 with
Norsk Tipping, 264 scorers, 315 assists. Full suite 1300+ passed, pytest exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
Zero callers since `fixtureFor(teamId, gw)` superseded it. Harmless while
nothing called it, and exactly the shape that gets picked up by the next person
grepping for a fixture helper: it returned plausible, correctly-shaped data for
a round already played.

The issue called it "the last known instance" on 2026-08-27. It was not — two
more were found this week, `FX_START = 1` in the fixtures grid and nine `GW1`
literals in index.html, the second of which printed "GW1" above gameweek 3's
fixtures on screen. Deleting a dead one is cheap; the reason to bother is that
this family keeps producing live ones.

The surviving comment says what was removed and why, worded so it does not read
as the function coming back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
Four reports in one session, three of which turned out to be the same mistake
wearing different clothes: a value DERIVED where it should have been READ.

YOUR MONEY WAS ARITHMETIC, NOT YOUR MONEY. Reported as "says I have 4.7
million, but I've bought Lewis Hall for 5.0, which was the exact amount of
money I had". The app computed `BUDGET - sum(current prices)`; FPL says the
bank is 0.0. Three layers of the same error:

  * the bank itself, now taken from `entry/{id}/event/{gw}/picks/` and labelled
    `bank_source: FPL | ESTIMATE` so an estimate can never pass for a fact;
  * `data/squad.json`, which was a hand-written list that had drifted to 14 ids
    containing neither Maguire nor Hall, now synced;
  * `validate_squad`, which then flagged the real squad `budget exceeded:
    1003 > 1000`. It compared today's prices against the 100.0 a new manager
    starts with. That team cost 100.2, is worth 100.3, holds 0.0, and FPL
    enforced its legality when it was picked. Measured against its own value
    plus bank now; a hand-built squad still gets the constant.

Adding the 15th player briefly made the old formula report a bank of MINUS 3,
which is that derivation showing its own absurdity.

`POST /api/squad` also wrote `{"picks": ...}` and nothing else, so editing the
squad by hand would have silently deleted the synced bank and lineup beside it.
It merges, and drops `synced_gw`, because a hand-picked squad must not keep a
stale sync vouching for it.

THE SQUAD NOW SYNCS ITSELF, keyed on the DEADLINE. The first version chose "the
round in play, else the last finished" — and GW3's deadline is a Thursday with
its first match on Saturday, so for a whole day nothing is in play and the
newest finished round is still GW2. It would have shown the previous team
through exactly the window "when the deadline passes, you should show it
automatically" is about. FPL publishes a team when it LOCKS. That is the third
time this repo has picked the wrong one of deadline/started/finished.

THE APP KEEPS ITS OWN DATA CURRENT. `autorefresh.Refresher` re-fetches
bootstrap AND fixtures every five minutes in a daemon thread — the actual fix
for issue #17, where `fixtures_raw` was written once at startup and a server up
since the previous morning believed no GW2 match had kicked off. Four
properties, each a way an auto-refresher is worse than none: atomic or nothing,
a failure leaves the old data standing and records itself, the AGE is published
on /api/status (None before the first success, never 0), and it will not
overlap itself.

FIXTURE RUNS SHOWED ROUNDS ALREADY PLAYED. Reported with Frimpong, who had
played gameweeks 1 and 2 and was still offered them. `[1, 2, 3, 4, 5, 6]` was
written by hand in FOUR renderers — the player card, the players table, the
transfer target cards, and `avgFixtureDiff`, which averaged the difficulty of
played rounds and presented it as a forecast. The card's headline stat also
read "GW1 xPts" over gameweek 3's number, from a variable literally named
`gw1`, while the chart below it correctly said "PROJECTION GW3-8".

Sites seven through eleven of this family. None of the three guards built for
it could see them: the backend grep globs .py, the markup guard reads
index.html and looks for the STRING "GW1", and these were a list of integers in
a .js file. A mechanical check is only as wide as its glob AND its pattern.

MAX_RESIDUAL WAS A THRESHOLD WITH NO READER. Its own comment said "Reported,
not silently kept" and nothing ever compared anything to it — `is_prior` one
module over. Connected in `market_lambdas`, and FORCED with a synthetic
fixture, because no real one has ever breached it: the worst residual across
every banked market file is 0.0149 against a limit of 0.05. A branch that
cannot be triggered by real data is not a branch until something triggers it
deliberately.

COMPARE WAS INVISIBLE, TWICE OVER. "I still cant find compare?" — it was
rendering and passing an is_visible() check at 70x20px in --text-muted on
--surface-high, beside an amber badge that took the whole row. It carries the
accent at rest now. And it opened an empty box demanding two typed characters,
then searched all ~600 players: that answers "compare him with X", not "compare
him with the other defenders". It opens on the player's own position, ranked by
projected points, and typing still escapes the filter.

Entry 6261 is recorded in .env and in memory. The reader had to give it twice.

Verified live: bank 0.0 from FPL, 15 picks, no violations, refresher age 1s.
Full suite 1366 passed, 0 failed, pytest exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
Asked for directly: "a full extremely thorough check of the website, that not
similar buggs are in it." Every one was found by driving the running app and
reading what it rendered. The suite was green before and after.

  clean sheets      cs_by_gw["1"] against a backend serving 3-8, so all
                    twenty clubs rendered "—" in the Ours column. The refusal
                    machinery worked perfectly, which is what made a WIRING
                    break look like a data absence.
  Nott'm Forest     the match card would not open. esc() turns the apostrophe
                    into &#39; and the HTML parser turns it back BEFORE the JS
                    parser runs, so the onclick string closed early. The key
                    now travels in data- attributes, where it is never code.
  Run faced         Object.keys(t.gws).slice(0, 6) took the season's first six
                    from an endpoint that serves the whole season. Not a
                    label: xG/match and run rank were averaged over it.
                    Tavernier moved 1.48 -> 1.59, rank 9/20 -> 6/20.
  /api/captaincy    409 on a bare request: gw defaulted to the literal 1. The
                    refusal was right and the QUESTION was wrong.
  deadline fallback a baked-in GW1 date whose own comment predicted this exact
                    moment. Latent only because /api/status kept serving one.
                    Also: new Date(null) is the epoch, not NaN, so an absent
                    deadline rendered as one that had passed.
  Known wrong       a hand-written statistic, now INVERTED — it said away
                    sides run 17% under the book; measured, home runs +15%
                    over and away is level. Derived from MARKET.teams now.
  disclosures       expanding a transfer suggestion collapsed the panel
                    holding it. SWAP_OPEN survived every re-render; <details
                    open> is DOM state and did not.

Also wired CLEAN_SHEET_MIN_MINUTES, which had no reader while both simulator
paths hardcoded 60, and retired LIVE_TTL_SECONDS (#10) — superseded by the
push-based refresher rather than needing a better number.

46 new tests, each proved RED against the unfixed code first. Full suite green
(PYTEST_EXIT=0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
Asked for after the audit listed what was still manual: "Do it."

backend/web/chores.py runs, in a daemon thread beside the autorefresher:

  after a round is over      after_gameweek.py --gw N   (ingest, rebuild, score)
  before a deadline (3h)     NT pull -> nt_resolve_market -> snapshot_pit

It SHELLS OUT to the same scripts a person runs. One code path, so the
automated route cannot drift from the one that gets debugged; the ~170s
rebuild stays out of the request thread; and the cache arrives through the
mtime hot-reload that already guards against a corrupt rebuild.

What is due is decided from FACTS every tick — "GW2's fixtures are all over
and no ingest is recorded for GW2" — never from a timer, which cannot be
reasoned about after it misses. The ledger is keyed on the round, versioned,
and records the outcome rather than the attempt.

Verified end-to-end on live data: it detected GW1, ran the chain unprompted,
and the server hot-reloaded the cache it built (built_at 12:40:12Z).

Four defects found while building it, three of them in the module written to
enforce the rules they break:

  * finished_gameweek was a THIRD copy of "this round is over", written hours
    after CLEAN_SHEET_MIN_MINUTES was wired for that exact reason. Delegates
    to season.playable_gameweeks now.
  * MISSED keyed on the absence of a ledger entry, not the presence of a
    snapshot — so the header announced "no snapshot for GW1, GW2" while
    data/pit held seven and six, taken by hand. Reads the manifests now.
  * the events lookup found nothing (bootstrap is an object, not a dict) and
    only the PUBLISHED status revealed it. Unsurfaced it would have sat idle
    all season, looking exactly like a scheduler with nothing due.
  * "GW3 deadline in 124.9h" three seconds into a rebuild. A working
    scheduler and an idle one must not produce the same sentence.

Mutation pass: 12 guards cut one at a time, 10 red immediately; "runs once"
is held by two independent guards (red when both are cut); one test could not
fail at all and was rewritten to assert the message rather than a subprocess
count that was true by construction.

37 new tests. Full suite green (PYTEST_EXIT=0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
`data/chores.json` records what THIS machine has ingested and snapshotted, the
same class of state as data/squad.json and data/cache/ — both already ignored.

Committing it hands a fresh clone a completion marker for work that never
happened there, which is precisely what the ledger exists to not be. Its own
module docstring says a completion marker is not evidence of what was
completed; shipping one in git is that sentence with a repository attached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
Both user-reported.

"For Gameweek 3 you have projected Semenyo to score more than Fernandes, but
you are choosing Fernandes as captain?" — measured that minute:

    pitch (gold C)   B.Fernandes  4.1
    armband panel    CAPTAIN · GW3 → Semenyo, "0.64 pts to switch", DECIDED

Two captains on one screen. Neither number was wrong; one of the QUESTIONS
was. /api/best-xi sorts the fifteen by SIX-WEEK ev to pick a lineup and then
returned `captain = xi[0]` — the top of that sort. /api/captaincy ranks the
one gameweek being captained, jointly with the vice, on P(appears) and
E[points | appears]. Fernandes leads over six weeks, Semenyo leads GW3, and
the armband is re-picked every week.

best_xi now asks rank_captaincy for the same gameweek, so the two share one
computation rather than being two kept in step. It refuses rather than falling
back: no captain, and `captain_refused` says why. Also deleted
captainCandidates(), a third ranking by the same wrong quantity with no caller.

"And still no compare button when i see the playercard" — reported twice, and
present both times: 82x26px at 11.5px type, at the tail of the subtitle
run-on, under a grid of large numbers. Every test asserting it EXISTED passed
honestly while the person it was built for could not find it. Existence is not
discoverability and a DOM query cannot tell them apart. Moved into .pm-head
beside the close ×, 13px, reading "⇄ Compare MIDs". The new test asserts
placement and legibility, not presence.

Two things caught by testing rather than reasoning:

  * the pitch pager was ALREADY correct — shirtCard carries
    `if (viewGw() === thisGw())` with a comment saying exactly why. I had
    concluded from the call site that it was unguarded. Regression test kept,
    and proved to bite by cutting that guard.
  * the fix made /api/best-xi 500 on an older cache (KeyError: 'p10'). The
    refusal was right and its blast radius was not: a lineup endpoint needs
    nothing from p10. It degrades and states why; /api/captaincy still refuses
    outright, because there the armband is the answer.

Full suite green (PYTEST_EXIT=0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
Third report, with a screenshot: "I still cant find the compare button
though." The photo showed a card headed "PER GAMEWEEK · XPTS, NO BONUS" —
a DIFFERENT card from the one being fixed.

This app has two player cards, rendering into the same #player-modal element:

    Players tab, table row   openPlayerModal()    had the control
    Squad tab, a pitch shirt playerDetailHtml()   never had one

Every compare test drove the Players tab. All passed, three times, while the
reader opened players from his own squad and found nothing. Because both cards
share a DOM node, no selector check could tell them apart — `.cmp-toggle` was
always found, on whichever card the test happened to open. A guard is only as
wide as the ENTRY POINT it drives.

The new test drives the pitch and opens with a guard asserting it really did
open the other card, because the failure mode is a fixture that silently tests
the surface already fixed.

Placement, asked for directly ("should be logical, and thoughtful"): the
header-row answer was also wrong — that corner is the DISMISS, and an action
beside the × competes with the one control every reader already knows. It has
its own row now, under the name and above every number, on BOTH cards: who he
is, what you can do about him, then the evidence.

Label is just "Compare". It read "Compare MIDs"; the position is in the card
header two lines up and repeating it is noise.

`#pm-compare-host` was added to the pitch card too — without it the button
would have been present and inert, which is worse than absent.

Full suite green (PYTEST_EXIT=0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
Asked for: a squad meant to last to GW25, "with a view to GW28, even though
25-28 should carry lower weight" — plus the players sitting just behind the
picks.

`build_full` takes `window_size` (default unchanged, and build_cache.py
deliberately does not pass one, since every number the app serves is summed
over that window). scripts/long_horizon.py runs THROUGH it rather than
reassembling the setup beside it, and writes to data/analysis/, never to the
cache the site reads.

MEASURED, GW3-28, 26 gameweeks, 566s:

  * the discount barely matters. Across decay 0.5 / 0.75 / 1.0 the top six per
    position is identical except Thiaw and Truffert swapping 5th and 6th.
  * fixture luck in the SHORT window is ±8%: Newcastle's GW3-8 rate is 1.08x
    its own 26-week norm, Sunderland's 0.91x. Over the full horizon it is zero
    by construction, so the long list ranks players and club quality, not
    opponents.

THE FIRST VERSION OF THAT SECOND NUMBER WAS WRONG and said 101% — "fixtures
matter just as much over 26 gameweeks". It took the spread of clubs' absolute
means and compared windows, which measures club STRENGTH, a permanent thing:
Man City out-score Coventry every week whatever the fixture. The metric could
not see what its own label claimed. It was caught only because the prediction
had been written down before the run, which is the whole reason that rule
exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
docs: correct why depth.py is unwired — the stated blocker was the wrong one
Some checks failed
CI / syntax (pull_request) Has been cancelled
CI / tests (3.11) (pull_request) Has been cancelled
CI / tests (3.12) (pull_request) Has been cancelled
CI / wheel (pull_request) Has been cancelled
CI / browser (pull_request) Has been cancelled
9904afec80
Answering issue #4 meant reading depth.py properly, and the reason CLAUDE.md
gave for leaving it off was wrong in the direction that keeps a working
mechanism switched off. It said FPL's four position buckets cannot express a
depth chart. That describes REASSIGNING minutes, which the module explicitly
refuses to do — its docstring spends a paragraph saying knowing Saliba is out
tells you the minutes are available, not who gets them. Its only output is a
refusal plus the reason.

So the entry sent two readings looking for detailed position data that is not
needed and, per the 2026-07-31 Understat probe, does not exist.

Measured instead: blockers_for on the live index, GW3, refuses 11 of 623
(1.8%), and the accuracy splits by position. DEF spans CB/FB and does
tolerably (Calafiori and Hincapié behind Saliba are exactly the case it was
written for); MID spans a defensive midfielder and two wingers, so one absent
midfielder refuses three players whose roles it says nothing about.

About half would be wrong, and a refusal drops the player from the candidate
pool. Disposition is neither wire nor delete but demote to a badge — keep the
detection, drop the start_source write. Render the row, mark the cell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzuxHvACMHg8u1AhdK6k5
test: make the full suite honest in a clean checkout
Some checks failed
CI / syntax (pull_request) Has been cancelled
CI / tests (3.11) (pull_request) Has been cancelled
CI / tests (3.12) (pull_request) Has been cancelled
CI / wheel (pull_request) Has been cancelled
CI / browser (pull_request) Has been cancelled
a304c05e2c
ci: target the configured Python runner
Some checks failed
CI / browser (pull_request) Failing after 17s
CI / wheel (pull_request) Failing after 17s
CI / tests (3.11) (pull_request) Failing after 17s
CI / tests (3.12) (pull_request) Failing after 17s
CI / syntax (pull_request) Failing after 20s
67a8212836
ci: use Python supplied by the runner image
Some checks failed
CI / tests (pull_request) Failing after 5s
CI / wheel (pull_request) Failing after 6s
CI / browser (pull_request) Failing after 6s
CI / syntax (pull_request) Successful in 12s
702fe9b056
ci: install dependencies in isolated virtual environments
Some checks failed
CI / wheel (pull_request) Failing after 2s
CI / syntax (pull_request) Successful in 11s
CI / browser (pull_request) Has been cancelled
CI / tests (pull_request) Has been cancelled
e8d628759a
ci: serialize jobs within runner capacity
Some checks failed
CI / syntax (pull_request) Failing after 2s
CI / tests (pull_request) Has been skipped
CI / wheel (pull_request) Has been skipped
CI / browser (pull_request) Has been skipped
1f971a01ed
ci: use Node supplied by the runner image
Some checks failed
CI / syntax (pull_request) Failing after 1s
CI / tests (pull_request) Has been skipped
CI / wheel (pull_request) Has been skipped
CI / browser (pull_request) Has been skipped
e4f1deb0e9
Sign in to join this conversation.
No description provided.