{"id":51259853,"url":"https://github.com/manganite/wm2026","last_synced_at":"2026-06-29T11:02:06.686Z","repository":{"id":363301667,"uuid":"1262284582","full_name":"manganite/wm2026","owner":"manganite","description":"Client-side Monte-Carlo simulation of the 2026 FIFA World Cup","archived":false,"fork":false,"pushed_at":"2026-06-24T05:15:14.000Z","size":511,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-24T07:15:48.054Z","etag":null,"topics":["data-visualization","elo-rating","fifa","github-pages","javascript","monte-carlo-simulation","react","soccer","sports-analytics","sports-prediction","vite","worldcup2026"],"latest_commit_sha":null,"homepage":"https://manganite.github.io/wm2026/","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/manganite.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-06-07T20:04:48.000Z","updated_at":"2026-06-24T05:15:18.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/manganite/wm2026","commit_stats":null,"previous_names":["manganite/wm2026"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/manganite/wm2026","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manganite%2Fwm2026","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manganite%2Fwm2026/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manganite%2Fwm2026/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manganite%2Fwm2026/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/manganite","download_url":"https://codeload.github.com/manganite/wm2026/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manganite%2Fwm2026/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34923767,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-29T02:00:05.398Z","response_time":58,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["data-visualization","elo-rating","fifa","github-pages","javascript","monte-carlo-simulation","react","soccer","sports-analytics","sports-prediction","vite","worldcup2026"],"created_at":"2026-06-29T11:02:04.360Z","updated_at":"2026-06-29T11:02:06.674Z","avatar_url":"https://github.com/manganite.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# WC 2026 Monte-Carlo Simulation\n\n**Live app: https://manganite.github.io/wm2026/**\n\n![Progression panel showing each team's title probability broken down by stage reached](docs/images/screenshot.png)\n\nClient-side Monte-Carlo simulation of the 2026 FIFA World Cup. Runs entirely\nin your browser: no backend, no server. The engine samples thousands of\ncomplete tournaments — respecting the real schedule, official knockout bracket,\nand FIFA tiebreaker rules — and aggregates the results into the statistics\nshown in the app. Played results are hand-maintained in one JSON file; every\nmatch not yet played is simulated.\n\n---\n\n## What the app shows and how it's calculated\n\n### Title and stage probabilities (\"Tournament outlook\" table)\n\nThe engine runs **N complete tournaments** (default 15 000, adjustable in the\nUI up to 100 000). In each run it\nsimulates every unplayed match by sampling a scoreline from the match model\n(see below), applies the official tiebreaker rules to determine group rankings,\nand propagates winners through the knockout bracket all the way to a champion.\n\nThe probabilities in the table are simply **counts / N**:\n\n- **Reach R32** — fraction of runs where this team advanced from the group stage.\n- **Reach R16 / QF / SF / Final** — fraction of runs where they reached at least that round.\n- **Title** — fraction of runs where they won the Final.\n\nThese are *cumulative* — a team that wins the tournament is counted in all six\ncolumns, not just \"Title\". Stage probabilities therefore always decrease from\nleft to right.\n\n### \"How far will each team go?\" (Progression chart)\n\nEach horizontal bar is a **probability distribution over exit stages**: it\nshows the probability that this team's tournament *ends exactly at* each\nround. The segments are the differences between consecutive cumulative\nprobabilities — e.g. the \"Lost in R32\" segment is `P(reach R32) − P(reach\nR16)`. All segments for one team sum to 100%.\n\nTeams are ranked by title probability. The chart shows the top 12 by default;\nclick \"Show all 48 teams\" to expand.\n\n### Timeline — how the picture has evolved\n\nEvery other section answers \"what does the model think *now*?\". The Timeline\nsection answers \"how did we get here?\": how each team's title probability,\nexit-stage distribution, and group-qualification odds changed as real results\nwere entered, match day by match day.\n\n**No history is stored.** Each point on the timeline is computed from\nscratch: the app takes the subset of `results.json` whose fixtures were\nplayed on or before that date, conditions the engine on just that subset, and\nre-runs the Monte-Carlo simulation. The seeded RNG (`DEFAULT_SEED`) makes\nevery point reproducible, so the whole timeline is always derivable from\n`fixtures.json` (dates, including the knockout match dates) + `results.json`\n(scores) alone — there's a `t0` point (empty conditioning, the\npre-tournament prior) plus one point per date that has at least one entered\nresult.\n\n- **Title probability over time** — a line per top team (`probs[code].W` at\n  each timeline point) plus a \"Field\" line for everything else, with vertical\n  markers for the end of the group stage and the start of each knockout\n  round. Hover a point to see that day's results and each visible team's\n  change since the previous point.\n- **Match impact** — for each match day, the matches played and the biggest\n  title-probability movers (the deltas behind the line chart above), newest\n  first.\n- **Stage distribution over time** — for a chosen team, the same exit-stage\n  breakdown as the Progression chart above, but as a stacked area evolving\n  across the timeline instead of frozen at \"now\".\n- **Group qualification races** — per group, each of its four teams'\n  probability of reaching the R32, through the end of the group stage.\n  Best-third uncertainty can keep these moving even after a team's own group\n  has finished, since it depends on how the *other* groups' third-placed\n  teams compare.\n\n**Run-count caveat**: timeline points use `HISTORY_RUNS = 5000` simulations\n(vs `DEFAULT_RUNS = 15 000` for the live \"Tournament outlook\" table above), so the\ntimeline's most recent point can differ from that table by roughly a\npercentage point — plenty of precision for a curve, but not identical.\n\n**Caching**: each point is cached in the browser's `localStorage`, keyed by a\nhash of its result subset, `HISTORY_RUNS`, the seed, and `ENGINE_VERSION`\n(bumped whenever `engine.mjs`'s simulation logic changes, invalidating\npreviously-cached points). Only new or changed match days are recomputed, in\na dedicated Web Worker, so the timeline never blocks the rest of the UI.\n\n### Per-match predictions (\"Fixtures\" panel)\n\nFor every match whose participants are already known, the app shows four\nblocks derived analytically from the match model (not from MC sampling, so\nthey don't depend on N):\n\n**Tendency** — the overall win/draw/loss probability for each side. Computed\nas the sum of all scoreline probabilities in the home-win, draw, and away-win\ncells of the score matrix respectively.\n\n**Most likely score, by outcome** — for each of the three outcomes (home win,\ndraw, away win), the single most probable exact scoreline *within that outcome*,\nwith its conditional probability (e.g. \"given a home win, 1:0 has a 34%\nchance\"). This is more informative than the single global most-likely score,\nwhich in low-scoring Poisson-ish football is very often 1:1 or 0:0 even when\none side is a clear favourite — because one draw cell can be the single most\nprobable cell even when the total probability of all win cells is larger.\n\n**Top 5 scorelines** — the five highest-probability individual scorelines in\nabsolute terms (unconditional), with each one's probability.\n\n**Expected goals (xG)** — the raw λ_home : λ_away values from the Elo-to-lambda\nconversion (see Match model below). This is the *expected number of goals per\nside* under the Poisson model before the Dixon-Coles correction is applied.\n\n### Knockout bracket\n\nSlots for matches not yet played show a list of teams with their probability of\nfilling that slot. These are also MC counts: across all N runs, the engine\nrecords which team filled each bracket slot, and divides by N. Since `RATING_SIGMA`\nnoise is applied per run, these reflect genuine uncertainty about who advances.\nOnce a match is played and its result entered, that slot collapses to ~100% for\nthe real participant in every subsequent run.\n\n### Performance vs. expectation\n\nTracks how each team has performed relative to the model's pre-match Elo-implied\nexpectations (not shot-based xG). Two charts:\n\n- **Match performance** — per-team over/under-performance in goal difference\n  (all matches) and points (group stage only) compared to what the model's\n  pre-match score matrix expected. A persistently over-performing team may\n  simply be underrated by Elo.\n- **Progression vs. expected** — how far each team has actually gone vs. how\n  far the pre-tournament model expected. Only eliminated teams show a final\n  delta; teams still alive are provisional (shown with faded bars).\n\n### Model report card\n\nA running track record of how well the model's pre-match predictions have\nmatched the real results entered so far:\n\n- **Accuracy summary** — overall Brier score and log-loss across all played\n  matches, plus tendency accuracy (how often the predicted favourite actually\n  won).\n- **Accuracy over time** — how the running Brier score and log-loss have\n  evolved as results come in, plotted match by match.\n- **Calibration diagram** — bins predicted probabilities and checks whether the\n  model's stated confidence matches observed frequency (e.g. do events\n  predicted at 70% actually happen ~70% of the time?).\n- **Per-match scorecard** — every played match with the model's pre-match\n  prediction, the actual result, and the surprise score (how unlikely the\n  real outcome was under the model).\n\n### Early clinch and elimination detection\n\nWhile a group is still in progress, the app detects teams that have\nmathematically clinched advancement or been eliminated using a points-based\nanalysis of remaining matches. Clinched or eliminated teams are marked in the\ngroup standings tables — this is computed from the remaining-match combinatorics,\nnot from the Monte-Carlo simulation.\n\n---\n\n## Match model\n\n### From Elo to goal expectation\n\nEvery team has an Elo rating (World Football Elo — see `data/teams.json`).\nFor a match between home team H and away team A:\n\n```\nsup   = (elo_H − elo_A + HOME_ADV) / ELO_PER_GOAL   ← goal supremacy\nλ_H   = (BASE_TOTAL + sup) / 2                        ← home expected goals\nλ_A   = (BASE_TOTAL − sup) / 2                        ← away expected goals\n```\n\nBoth are clamped to a minimum of 0.12 so very lopsided Elo gaps don't produce\nnear-zero lambdas. `HOME_ADV = 0` because World Cup matches are at neutral\nvenues. Expected goals are symmetric: a 220-point Elo lead translates to\nroughly one extra expected goal.\n\n### Dixon-Coles score matrix\n\nGoals are **not** treated as independent Poisson draws. Instead the engine\nbuilds a full 11×11 scoreline matrix (0–10 goals per side) and applies the\nDixon-Coles correction to the four low-score cells:\n\n```\nP(h, a) ∝ Poisson(h | λ_H) × Poisson(a | λ_A) × τ(h, a)\n\nτ(0,0) = 1 − λ_H · λ_A · ρ\nτ(1,0) = 1 + λ_A · ρ\nτ(0,1) = 1 + λ_H · ρ\nτ(1,1) = 1 − ρ\nτ(h,a) = 1   for all other (h,a)\n```\n\nWith `RHO = −0.06` (negative), the correction slightly *decreases* the\nprobability of 0:0 and 1:1 draws and slightly *increases* 1:0 and 0:1. This\nmatches the empirical finding that low-score draws are somewhat less frequent\nthan independent Poisson predicts. The matrix is then renormalised to sum to 1.\n\nA simulated match samples one (h, a) cell from this matrix using the CDF.\n\n### Per-run Elo noise (RATING_SIGMA)\n\nA pure Elo model is too confident about favourites: without uncertainty about\ntrue team strength, the best-rated side accumulates small probability edges\nover seven knockout games that compound into an unrealistically large title\nprobability. To counteract this, the engine draws a fresh random Elo offset\nfor every team at the start of each MC run:\n\n```\nelo_eff(team) = elo(team) + N(0, σ)    where σ = RATING_SIGMA = 100\n```\n\nThe noise is drawn using a Box-Muller transform from the seeded RNG and is\napplied consistently across both the group stage and all knockout matches in\nthat run. A σ of 100 Elo points represents \"we don't know the true strength to\nbetter than roughly ±100 points\" — it fattens the tail of the title\ndistribution and regresses extreme favourites toward the field without changing\nthe average ranking. This knob was calibrated against bookmaker outright-winner\nodds (see Calibration check below); σ=100 roughly halves the Spain/Argentina\nover-confidence versus the market.\n\n**This noise is never applied to `predictMatch` (the display function)** — xG\nand tendency are computed from the real Elo values, not the per-run noisy ones.\n\n### Knockout match resolution\n\nAn unplayed knockout match is resolved in sequence:\n\n1. **90 minutes** — sample a scoreline from the score matrix.\n2. **If level after 90 min: extra time** — sample a *second* scoreline from a\n   matrix built with `λ × ET_FACTOR` (default ⅓ of a normal match). Add to\n   the 90-min score.\n3. **If still level: penalty shootout** — a near-coin-flip weighted by Elo:\n   `p(home wins) = 1 / (1 + 10^(−ΔElo / 2000))`. This is the standard Elo\n   win-probability formula applied to the shootout specifically.\n\nFor **played** knockout matches the entered score is used directly (not\nre-sampled). If the score is level and a winner token is provided in\n`results.json`, that token is authoritative. If the score is level and no\ntoken is provided, the engine falls back to the penalty model — but you should\nalways add the token for real results (see `results.json` format below).\n\n---\n\n## Model parameters\n\nAll knobs live in `PARAMS` in `engine.mjs`. Change them there; the verifier\nwill catch if they break consistency.\n\n| param | meaning | default |\n|---|---|---|\n| `BASE_TOTAL` | average total goals in an evenly matched game | 2.65 |\n| `ELO_PER_GOAL` | Elo gap worth roughly 1 goal of supremacy | 220 |\n| `HOME_ADV` | home-side Elo bonus (0 = neutral WC venues) | 0 |\n| `RHO` | Dixon-Coles dependence; negative = slightly fewer 0:0/1:1 draws | −0.06 |\n| `ET_FACTOR` | extra-time goal rate as a fraction of a full match | 1/3 |\n| `RATING_SIGMA` | std-dev of per-team-per-run Elo noise (σ=0 disables) | 100 |\n\n---\n\n## Calibration check\n\nRunning `node verify.mjs` prints a side-by-side comparison of the model's\ntitle probabilities against a captured snapshot of bookmaker outright-winner\nodds (`data/odds.json`). The check converts American odds to\noverround-free implied probabilities (divides each by their sum to strip the\nbookmaker margin) so both sides sum to 1.0 before comparing.\n\nTwo summary statistics are reported:\n\n- **Mean absolute difference (MAD)** — average |model% − market%| across the\n  quoted teams. Lower is better; a few percentage points is normal.\n- **Spearman rank correlation (ρ)** — agreement on *who is more likely than\n  whom*. 1.0 = perfect rank agreement; the number itself matters more than\n  the individual point gaps.\n\nThe snapshot captured 2026-06-07 (FanDuel, via Fox Sports — see\n`data/odds.json`) with `RATING_SIGMA = 100` showed: Spearman ρ = 0.83, MAD =\n2.1 percentage points. The largest remaining gaps were Spain (+5.5pp) and\nArgentina (+6.2pp) rated higher than the market, and England (−5.2pp) and\nFrance (−4.7pp) rated lower — likely because Elo and bookmaker prices weigh\ndifferent things (squad news, injury reports, historical pedigree, market\nsentiment). This is a **plausibility signal, not a pass/fail gate** — both\nthe Elo feed and the bookmaker odds snapshot drift over time.\n\nRe-run `node scripts/calibrate.mjs` after any Elo update to sweep\n`RATING_SIGMA` and check whether the tuned value still minimises MAD.\n\n---\n\n## Engine API\n\n```js\nimport { runMonteCarlo, predictMatch } from \"./engine.mjs\";\n\nconst { probs, predictions, slotAdvancement } = runMonteCarlo(data, results, N, seed);\n\n// probs[teamCode] = { R32, R16, QF, SF, F, W }  — fraction of N runs where\n//                   the team reached at least that stage (cumulative).\n\n// predictions[]   — one entry per group match with known participants:\n//                   { mostLikely, mostLikelyByOutcome, top5, tendency, expectedGoals }\n//                   or { played: true, score: [h,a] } for entered results.\n\n// slotAdvancement[slotId] — [{ code, prob }, …] sorted high→low: which teams\n//                            filled that bracket slot, and how often.\n```\n\n`predictMatch(eloHome, eloAway)` — returns the descriptive prediction for one\nfixture: most likely exact score, most-likely-by-outcome for each of the three\nresults, top 5 scorelines, W/D/L tendency, and expected goals. Uses the real\nElo values, not per-run noise.\n\n---\n\n## Repo layout\n\n```\ndata/teams.json      48 teams: code, name, group, confed, elo (live ratings snapshot)\ndata/fixtures.json   72 group matches (real schedule) + full knockout structure\ndata/results.json    hand-edited played results: { matches: { \"GE1\": [3,0], ... } }\ndata/odds.json       dev-only snapshot: bookmaker odds for the calibration check\nengine.mjs           simulation engine (no DOM) — runs in Node and browser\nverify.mjs           Node script: runs MC, structural assertions, calibration check\nscripts/calibrate.mjs  sweeps RATING_SIGMA to find the value that minimises MAD\n```\n\n---\n\n## Run the verifier\n\n```\nnode verify.mjs\n```\n\nThe verifier runs the MC (40 000 simulations) and checks:\n\n- Title probabilities sum to ~1.0; R32 probabilities sum to ~32 (one per advancing team); stage probs are monotone.\n- **Annex C assertions** — exactly 495 rows, every row has 8 distinct A–L letters, the 495 rows cover all C(12,8) subsets exactly, no third-placed team is assigned to face its own group's winner.\n- **Bracket tree assertions** — R32 has 16 matches, R16 has 8, QF 4, SF 2, Final 1; every slot in each stage is a `{w: id}` reference pointing to a valid match in the preceding stage.\n- **results.json validation** — unknown match IDs, non-integer/negative scores, level knockout score with no winner token (error); knockout result entered before its feeder rounds are resolved (warning).\n- **Calibration check** against bookmaker odds (self-skips if `data/odds.json` is absent).\n\n---\n\n## Adapting to the live tournament\n\n`results.json` is the single source of truth. Each entry maps a match `id` to\n`[homeGoals, awayGoals]`. Anything absent is simulated. The same engine code\nruns pre-tournament (empty results) and at every later stage (more entries\nfixed).\n\n**Knockout matches decided on penalties** need the shootout winner as a third\nelement, since a knockout match must have a winner:\n\n```json\n\"R32-1\": [1, 1, \"BRA\"]   // 1-1 aet, Brazil won on penalties (team code)\n\"R16-3\": [2, 2, \"H\"]     // or \"H\"/\"A\" for home/away side\n\"QF-2\":  [2, 1]          // decisive score — no token needed\n```\n\nEntering a level score without a winner token is not a hard error — the engine\nfalls back to its penalty model — but it means the simulation is using a\nprobabilistic guess rather than the real outcome. The in-app validator will\nflag it with an error banner.\n\nTo update during the tournament: edit `data/results.json`, commit, push.\nThe deployed app fetches the file (raw GitHub URL) and re-runs automatically.\n\n---\n\n## Start points \u0026 projections (UI)\n\nBeyond simulating from the real entered results, the app can simulate forward\nfrom a hypothetical \"start point\" where undecided matches are filled in with\nthe model's modal outcome (`pickMostLikelyScore` in `src/lib/selectors.js`:\nargmax of tendency, then that outcome's most-likely conditional score).\n\n- **Pre-tournament** — only real entered results are conditioned on; nothing synthesized.\n- **After groups** — the 72 group matches are filled with modal outcomes; the knockout bracket shows advancement probabilities from the simulation.\n- **After R32 / After R16 / After QF / After SF** — group matches filled, then knockout matches resolved and synthesized stage-by-stage up to and including that round; later rounds still show simulated probabilities.\n- **Full tournament** — all 103 matches synthesized (group stage + all five knockout rounds, R32 through Final).\n\nThese are explicitly illustrative. Chaining the \"most likely\" pick in each\nmatch compresses into one low-probability path through the bracket — not a\nforecast of what will happen. The actual most-likely winner of the tournament\n(as measured by the title probability) is often *not* the same team that the\ndeterministic most-likely-chain produces.\n\nSynthesizing a complete bracket requires every tie to be broken — including\ncross-group best-thirds ties. The real bracket view rightly refuses to call\nthose (a genuine undecided lots draw), but a projection has no \"wait and see\".\n`buildKnockoutResolution` accepts a `{ tieBreakSeed }` that commits to a\nsingle, reproducible draw of lots — all projected start points pass the same\n`PROJECTION_TIE_BREAK_SEED` so the teams a synthesized score was computed for\nare exactly the teams the bracket displays it between.\n\n---\n\n## Deployment (GitHub Pages)\n\nLive at **https://manganite.github.io/wm2026/** — a static site, no backend.\n`.github/workflows/deploy.yml` builds and deploys on every push to `main`.\nThe build step runs `scripts/sync-data.mjs` to copy `data/*.json` into\n`public/data/` before the Vite build, so the browser-facing data files stay\nin sync with the source of truth.\n\n`vite.config.js` sets `base: '/wm2026/'` for the project-page URL structure;\nkeep that, the repo name, and `src/config.js`'s `GITHUB_OWNER`/`GITHUB_REPO`/\n`RESULTS_RAW_URL` in sync if the repo ever moves.\n\nThe Monte-Carlo runs in a **Web Worker** (off the main thread), so the UI\nstays responsive while the simulation churns. The raw-GitHub `results.json`\nfetch is cache-busted with `?t=Date.now()` to bypass the browser cache, but\nGitHub's CDN can still lag a few minutes behind a push — not a bug.\n\n---\n\n## Fidelity to FIFA's official rules\n\nFour areas that could easily have been left as simplifying stand-ins were\ninstead matched to the official 2026 regulations:\n\n- **Best-thirds assignment** uses FIFA's official Annex C lookup table verbatim\n  (495 rows — one per possible combination of which 8 of the 12 groups produce\n  a qualifying third-placed team), extracted from the WC 26 Regulations and\n  verified for full, gap-free coverage of all C(12,8) combinations — see\n  `thirdPlaceAssignments.mjs`.\n\n- **Elo values** are live World Football Elo ratings pulled from eloratings.net\n  (captured 2026-06-08 — see the `_comment` in `data/teams.json`). Ratings\n  drift after every match, so re-capture before relying on them for a date far\n  from the recorded snapshot.\n\n- **R16+ bracket adjacency** mirrors FIFA's official knockout schedule\n  (Match 73–104: R32 = 73–88, R16 = 89–96, QF = 97–100, SF = 101–102,\n  Final = 104) verbatim — see the `_comment` in `data/fixtures.json`.\n\n- **Group tiebreakers** follow Article 13 of the 2026 regulations: points,\n  then head-to-head mini-league (points/GD/GF from mutual matches, recursively\n  re-applied to still-tied subsets), then overall GD, overall GF. The two\n  remaining criteria — fair-play score and FIFA World Ranking — fold into a\n  random draw: this goals-only model has no card data to compute the former,\n  and Elo is not a substitute for the latter. The draw is an unbiased\n  resolution of a tie the model genuinely has no signal on. `pickBestThirds`\n  mirrors the same logic for the best-thirds chain.\n\n---\n\n## References\n\n**Match model**\n\n1. Dixon, M.J. and Coles, S.G. (1997). \"Modelling Association Football Scores\n   and Inefficiencies in the Football Betting Market.\" *Journal of the Royal\n   Statistical Society: Series C (Applied Statistics)*, 46(3), pp. 375–386.\n   DOI: [10.1111/1467-9876.00065](https://doi.org/10.1111/1467-9876.00065)\n   — the source of the Dixon-Coles τ correction applied to 0:0, 1:0, 0:1,\n   1:1 scorelines; also introduces the independent-Poisson baseline this\n   model extends.\n\n2. Maher, M.J. (1982). \"Modelling Association Football Scores.\" *Statistica\n   Neerlandica*, 36(3), pp. 109–118.\n   DOI: [10.1111/j.1467-9574.1982.tb00782.x](https://doi.org/10.1111/j.1467-9574.1982.tb00782.x)\n   — the original independent-Poisson goal model that Dixon-Coles extends.\n\n**Elo ratings**\n\n3. World Football Elo Ratings — [eloratings.net](https://www.eloratings.net).\n   Source of all team ratings used in this simulation (snapshot captured\n   2026-06-08, see `data/teams.json`). The site documents its update formula\n   and K-factor choices; the penalty-shootout win probability used here\n   (`p = 1 / (1 + 10^(−ΔElo/2000))`) is the standard Elo formula with\n   the same 2000-point scale factor eloratings.net applies to football.\n\n**Tournament rules**\n\n4. *FIFA World Cup 26™ Regulations* (2024). Fédération Internationale de\n   Football Association. Available at\n   [fifa.com](https://www.fifa.com/en/tournaments/mens/worldcup/canadamexicous2026).\n   — Article 13 (group-stage tiebreakers), Annex C (best-thirds assignment\n   table), and Matches 73–104 (official knockout bracket adjacency) are all\n   implemented verbatim in this simulation.\n\n**Bookmaker odds (calibration snapshot)**\n\n5. FanDuel Sportsbook outright-winner odds for the 2026 FIFA World Cup, as\n   reported by Fox Sports, captured 2026-06-07. Used only as a\n   plausibility check in `verify.mjs`; see `data/odds.json` for the full\n   snapshot and conversion methodology.\n\n**Inspiration**\n\n6. [Monte-Carlo-Simulation-Chess-Candidates-2026](https://github.com/Periculum/Monte-Carlo-Simulation-Chess-Candidates-2026)\n   — a Monte-Carlo simulation of the 2026 Chess Candidates Tournament, and\n   the inspiration for this project.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmanganite%2Fwm2026","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmanganite%2Fwm2026","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmanganite%2Fwm2026/lists"}