Norsk Tipping is the only book on the board, and it fetches itself #29

Open
solvreven wants to merge 2 commits from feat/nt-only-board into main
Owner

Stacked on #28 — the first commit here is that fix. Merge #28 first and this reduces to its own commit.

Outcome

Norsk Tipping becomes the only book on the served surfaces: match odds, anytime goalscorer, anytime assist. The consensus files stay on disk and score_goalscorer.py still scores both every round, so this is a code change, not a lost comparison.

NT does not open its markets all at once, which nothing had measured. Probed at two points before GW3's deadline:

goalscorer (Scorer mål) assist (Spiller gjør en assist)
T-83h 8 of 10 fixtures 0 of 10
T-50h 10 of 10 10 of 10

So goals open ~4 days out and assists ~2, per fixture — and pre_deadline_steps pulled once, in the three hours before the deadline. GW3's goalscorer prices were live for four days with nothing fetching them, and that single shot was also the round's only chance of ever getting assists.

nt_watch in chores.py fixes that. It keys on coverage — "does the newest banked run carry both markets for every fixture of the next round", answered from the manifests on disk — not on a clock and not on a completion marker, so a pull someone ran by hand counts. The denominator is the round's fixture count from FPL, never the run's. Interval tightens toward the deadline (12h / 6h / 90m / 30m); failures back off, doubling to a 6h cap. Runs prune to the newest three, with every manifest kept — 5 KB against 7 MB, and the only record of when each market opens.

Defects found while wiring it

Four producer-without-a-consumer, three created by the NT-only switch itself:

computed discarded by
disagreements — NT's club disagreements main.matches, so props_moved served empty
fixtures_without_market same; the board could not say which fixtures were unopened
gw{n}_market_nt.json from nt_resolve nothing rebuilt the cache, and only a rebuild reads it
n_fixtures counted match_odds printed "10 fixtures" under a table drawn from 8

The third is the sharpest: nt_resolve has been in the pre-deadline chore since it was written, and snapshot_pit then froze the input to a computation nobody performed. The chore is now pull → resolve → rebuild → snapshot, so the frozen cache is the one built from the frozen prices.

A label spanning two causes put a falsehood on screen. _unpriced_but_ours returned one flat list, and the panel printed "No book priced Mbeumo, Gyökeres, Watkins, Ekitiké, Havertz — the omission is the bookmakers' saying they are unlikely to feature". All five are in the two fixtures NT had not opened at all. The book had said nothing whatsoever and the screen reported a verdict. It returns {"declined": [...], "unopened": [...]} now, keyed on the FPL team id, because the two need opposite responses: distrust our rate, versus come back nearer kickoff.

What removing the consensus cost, measured

_books_expect_him read n_books / n_books_fixture and it worked — 133 players under 80% started 2% of the time against 65% above it. That share cannot be computed from one book, so the gate is retired rather than faked.

Not recorded as free. NT quoting a player is close to no filter (36–40 selections across two ~25-man squads), and the GW3 board showed the shape the share was built to catch: Wilson 6th on start_prob 0.12, Abraham 9th on 0.11, Kudus on 0.10. Reported rather than patched by raising our own floor — that would let the weakest number in the system decide membership. test_a_conditional_price_on_a_fringe_player_is_currently_kept pins it.

Verification

  • full suite: 1554 passed, 0 failed (pytest exit 0)
  • mutation pass: 22 cuts, 22 REDs. Four survived the first pass, all the same shape — two guards holding one property, so cutting either left the other standing. Each now has a test that isolates it, including a producer-to-consumer round trip that runs the real bank_run and feeds its manifest to nt_coverage.

🤖 Generated with Claude Code

https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N

Stacked on **#28** — the first commit here is that fix. Merge #28 first and this reduces to its own commit. ## Outcome Norsk Tipping becomes the only book on the served surfaces: match odds, anytime goalscorer, anytime assist. The consensus files stay on disk and `score_goalscorer.py` still scores both every round, so this is a code change, not a lost comparison. **NT does not open its markets all at once**, which nothing had measured. Probed at two points before GW3's deadline: | | goalscorer (`Scorer mål`) | assist (`Spiller gjør en assist`) | |---|---|---| | T-83h | **8 of 10 fixtures** | **0 of 10** | | T-50h | 10 of 10 | **10 of 10** | So goals open ~4 days out and assists ~2, per fixture — and `pre_deadline_steps` pulled **once**, in the three hours before the deadline. GW3's goalscorer prices were live for four days with nothing fetching them, and that single shot was also the round's only chance of ever getting assists. `nt_watch` in `chores.py` fixes that. It keys on **coverage** — "does the newest banked run carry both markets for every fixture of the next round", answered from the manifests on disk — not on a clock and not on a completion marker, so a pull someone ran by hand counts. The denominator is the round's fixture count from FPL, never the run's. Interval tightens toward the deadline (12h / 6h / 90m / 30m); failures back off, doubling to a 6h cap. Runs prune to the newest three, with every manifest kept — 5 KB against 7 MB, and the only record of when each market opens. ## Defects found while wiring it Four producer-without-a-consumer, three created by the NT-only switch itself: | computed | discarded by | |---|---| | `disagreements` — NT's club disagreements | `main.matches`, so `props_moved` served empty | | `fixtures_without_market` | same; the board could not say which fixtures were unopened | | `gw{n}_market_nt.json` from `nt_resolve` | nothing rebuilt the cache, and only a rebuild reads it | | `n_fixtures` counted `match_odds` | printed "10 fixtures" under a table drawn from 8 | The third is the sharpest: `nt_resolve` has been in the pre-deadline chore since it was written, and `snapshot_pit` then froze **the input to a computation nobody performed**. The chore is now pull → resolve → **rebuild** → snapshot, so the frozen cache is the one built from the frozen prices. **A label spanning two causes put a falsehood on screen.** `_unpriced_but_ours` returned one flat list, and the panel printed *"No book priced Mbeumo, Gyökeres, Watkins, Ekitiké, Havertz — the omission is the bookmakers' saying they are unlikely to feature"*. All five are in the two fixtures NT had not opened at all. The book had said nothing whatsoever and the screen reported a verdict. It returns `{"declined": [...], "unopened": [...]}` now, keyed on the FPL team id, because the two need opposite responses: distrust our rate, versus come back nearer kickoff. ## What removing the consensus cost, measured `_books_expect_him` read `n_books / n_books_fixture` and it worked — 133 players under 80% started 2% of the time against 65% above it. That share cannot be computed from one book, so the gate is retired rather than faked. **Not recorded as free.** NT quoting a player is close to no filter (36–40 selections across two ~25-man squads), and the GW3 board showed the shape the share was built to catch: Wilson 6th on `start_prob` 0.12, Abraham 9th on 0.11, Kudus on 0.10. Reported rather than patched by raising our own floor — that would let the weakest number in the system decide membership. `test_a_conditional_price_on_a_fringe_player_is_currently_kept` pins it. ## Verification - full suite: **1554 passed, 0 failed** (pytest exit 0) - mutation pass: **22 cuts, 22 REDs**. Four survived the first pass, all the same shape — two guards holding one property, so cutting either left the other standing. Each now has a test that isolates it, including a producer-to-consumer round trip that runs the real `bank_run` and feeds its manifest to `nt_coverage`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
fix: the asset guard hashed raw bytes, so it could only be green on one platform
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m7s
CI / wheel (pull_request) Successful in 29s
CI / browser (pull_request) Successful in 16m33s
83111ce2e0
test_asset_version.py hashed the four assets byte-for-byte. These are text
files and the repo has no .gitattributes, so CI checks them out LF on
debian-trixie while a Windows clone with core.autocrlf=true holds CRLF.
The digest therefore differed by platform, and the guard was green only
for whoever recorded it last.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
feat: Norsk Tipping is the only book on the board, and it fetches itself
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 54s
CI / wheel (pull_request) Successful in 28s
CI / browser (pull_request) Successful in 16m55s
42bb44b18a
Removes the six-book consensus from the served surfaces and makes NT the
market for match odds, anytime goalscorer and anytime assist. The consensus
files stay on disk and score_goalscorer.py still scores both every round,
so this is a code change, not a lost comparison.

NT does not open its markets all at once -- measured 2026-09-01/02, goals
open ~4 days out and assists ~2, per fixture -- so a single pre-deadline
pull could never carry both. nt_watch in chores.py polls on COVERAGE
("does the newest banked run carry both markets for every fixture of the
next round", answered from the manifests on disk) rather than on a clock
or a completion marker, so a pull someone ran by hand counts. The
denominator is the round's fixture count from FPL, never the run's.

Four producer-without-a-consumer defects surfaced while wiring it, three
created by the NT-only switch itself: disagreements and
fixtures_without_market were computed and discarded by main.matches;
gw{n}_market_nt.json was written by nt_resolve with nothing rebuilding
the cache to read it; and n_fixtures counted match_odds while the table
was drawn from a different subset.

_unpriced_but_ours no longer returns one flat list. "No book priced X --
the omission is the bookmakers' saying he is unlikely to feature" was
printed over five players in two fixtures NT had not opened at all, which
is a verdict reported where the book had said nothing. It returns
{"declined": [...], "unopened": [...]} keyed on the FPL team id, because
the two need opposite responses.

pre_deadline_steps is now pull -> resolve -> rebuild -> snapshot, so the
frozen cache is the one built from the frozen prices. snapshot_pit.odds_for
takes both market files; the raw NT run is zipped rather than pointed at,
since prune_nt_runs can delete the directory.

Retires _books_expect_him: the coverage-share gate cannot be computed from
one book. Reported rather than patched around -- raising our own start_prob
floor would let the weakest number in the system decide membership.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 54s
CI / wheel (pull_request) Successful in 28s
CI / browser (pull_request) Successful in 16m55s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/nt-only-board:feat/nt-only-board
git switch feat/nt-only-board

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff feat/nt-only-board
git switch feat/nt-only-board
git rebase main
git switch main
git merge --ff-only feat/nt-only-board
git switch feat/nt-only-board
git rebase main
git switch main
git merge --no-ff feat/nt-only-board
git switch main
git merge --squash feat/nt-only-board
git switch main
git merge --ff-only feat/nt-only-board
git switch main
git merge feat/nt-only-board
git push origin main
Sign in to join this conversation.
No description provided.