The season stops being hardcoded, the chores run themselves, and Norsk Tipping drives the market #19
No reviewers
Labels
No labels
architecture
cleanup
dependencies
performance
priority: high
priority: medium
reliability
security
testing
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
solvreven/FPL!19
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/gw2-window-and-provenance"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
8342b4cmakes 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.
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.scripts/score_goalscorer.py --gw 2compares NT against the consensus against a base-rate control on log loss and Brier; the scheduler ina89fb53runs it once GW2's last fixture ends.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 togw1_*.jsonafter 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 playersFITTEDto 404MARKET.e433040— the fixtures grid opened on GW1 for the rest of the season.281c7a9— nineGW1literals inindex.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
.pyand only odds paths; the markup guard readsindex.htmlfor the stringGW1; 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-xicaptained 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 Forestwould 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_advandrhopinned 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_fulltakes a horizon;scripts/long_horizon.pyranks to GW28 with the tail discounted. Writes todata/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 recently9904afe, where the stated reason for leavingdepth.pyunwired described reassigning minutes, which that module explicitly refuses to do.Verification
PYTEST_EXIT=0. ~250 new tests, each proved RED against the unfixed code first.Review guidance
Worth your attention, in order:
backend/web/chores.py— new subsystem, runs unattended. The failure modes are in the module docstring.pipeline.market_file+8342b4c— the bet, and its seam.backend/web/main.pybest_xi— the captain now comes fromrank_captaincy; it refuses rather than falling back.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_forrefuses 11 of 623, and the accuracy splits by position).`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>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>`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 in726c73b. 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_01BSzuxHvACMHg8u1AhdK6k5Fetcher 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_01BSzuxHvACMHg8u1AhdK6k5User-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_01BSzuxHvACMHg8u1AhdK6k5Asked 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_01BSzuxHvACMHg8u1AhdK6k5Three 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_01BSzuxHvACMHg8u1AhdK6k5Four 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_01BSzuxHvACMHg8u1AhdK6k5Asked 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 ' 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_01BSzuxHvACMHg8u1AhdK6k5Asked 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_01BSzuxHvACMHg8u1AhdK6k5Both 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_01BSzuxHvACMHg8u1AhdK6k5Third 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_01BSzuxHvACMHg8u1AhdK6k5Asked 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