# EPL 6 Forecast — Methodology

This document explains, in detail, how the predictions site (`/predictions`) computes its
probabilities for the ESOC Premier League Season 6. Numbers quoted as examples are from the
June 12, 2026 data snapshot and shift as new results come in.

---

## 1. What is being predicted

EPL 6 is a 4-team tournament:

- **Round robin (Weeks 1–3):** each team plays every other team once. A weekly match consists of
  **5 player pairings**, and each pairing is a **play-all-5 series** — all 5 games are played
  regardless of score. A weekly match therefore contains exactly 25 games.
- **Standings** are ranked by **total individual game wins** (max 75 per team), with tiebreakers in
  order: head-to-head weekly-match winner → weekly-match wins → team name (alphabetical). This
  mirrors the main site's leaderboard logic exactly, so the two sites can never disagree about the
  current table.
- **Grand Final (Week 4):** the top 2 teams play 5 pairings of **play-all-7** (35 games). 35 is odd,
  so there is no tie. The winner is the team with more of the 35 games.

The site forecasts: each team's probability of winning the title and reaching the Grand Final, the
full distribution of final standings, expected scores for every series and match, and per-game win
probabilities for every individual pairing.

All player pairings for the round robin are pre-assigned in the official schedule, so the simulator
knows exactly which 1v1s will be played — there is no lineup uncertainty until the Grand Final.

---

## 2. Data sources

| Data | Source | Freshness |
|---|---|---|
| Live results, schedule, rosters, auction costs | The official EPL 6 Google Sheet (same feed the main site uses) | Fetched on every page load + re-polled every 60 s |
| Ladder ratings (1v1 Supremacy Elo, rank, W–L, rating history) | [freefoodparty.com](https://www.freefoodparty.com) API | Committed snapshot (`data/ratings.json`), refreshed by running `node predictions/fetch-data.mjs` |
| Past tournament results | [Liquipedia (Age of Empires)](https://liquipedia.net/ageofempires/), CC BY-SA 3.0 | Committed snapshot (`data/history.json`), refreshed by running `node predictions/fetch-history.mjs` |
| Outsider ladder anchors (non-EPL players in the fit) | freefoodparty.com API | Committed snapshot (`data/outsiders.json`), refreshed by running `node predictions/fetch-outsiders.mjs` |

The tournament-history scraper parses ~100 DE-era event pages — EPL Seasons 3–5, the ESOC
Championships (Winter 2023 → Spring 2026), PK Tournament 5 (including its group-stage subpages),
Kings of the Old World, the 2021 Global Championship, and several dozen smaller events and
head-to-head showmatches (KEK-Tourney, Fair Player League, TakkCafe Cup, the
Weekend Shakedown and Big Boy Brawl series, Beat the Boss, Revolt Royale, Torneio Rei, the EGCTV
Blind Civ series, and more — a July 2026 category sweep of Liquipedia added ~45 pages / +456
games and measurably improved held-out prediction, see §8b) — and keeps **every 1v1 game**,
including games against players outside the EPL 6 rosters ("outsiders"). As of this writing that
is 3,556 games: 675 between roster players (across 91 pairings, used for head-to-head records)
and the rest involving ~250 outsiders (used in the rating fit — see §4). Pre-DE events (before
October 2020) stay excluded — a different game version is a different skill. A companion script (`fetch-outsiders.mjs`) looks up each recurring outsider's FFP
ladder rating so they can be anchored in the fit. Events from before the Definitive Edition era
are excluded.

Player names are joined across sources via a hand-verified alias map (e.g. roster "Dennis" =
FFP "✩ Denn1$ ✩" = Liquipedia "Denn1$" = EPL 5's "DennDenn"; "Daniel" = "A Sturdy Table" =
"TheNameDaniel"; "Hazza" = his GC2021-era name "Haitch"). The scraper prints every name that faced
a roster player but didn't resolve, so missing aliases are caught on each run. EPL 6's own games
are deliberately **not** in the history file — they enter the model live from the sheet (see §7),
which keeps the "odds over time" replay honest.

**Corrections & identity hygiene:** known errors on Liquipedia itself are patched in
`fetch-history.mjs`'s `CORRECTIONS` list (e.g. the EPL 5 grand final was 6–1 Keturboy, not the 5–2
recorded on the page — one map is miscredited). Corrections re-credit specific games by
event/date/players and re-apply on every scrape, so they survive data refreshes. The same file
also carries `OUTSIDER_ALIASES` (verified alt-account merges, e.g. AgeofKiller = Revnak,
ChefSudiste 2 = ChefSudiste) and `EXCLUDED_PLAYERS` (unverifiable alt accounts whose games are
dropped entirely, e.g. "Shrek" — an unknown identity can't be trusted as evidence).

**Manual games:** results not on Liquipedia (private showmatches, unlisted events) can be added by
hand to `data/manual-games.json` — one object per game with exact roster names, winner, date
(YYYY-MM-DD), and a free-text event label. They merge into the same pool as scraped games: same
recency weighting, counted in both the tournament-Elo fit and the head-to-head blend, and shown in
H2H tooltips under their event label. Invalid entries (unknown names, bad winner/date) are skipped
and reported in a banner on the site.

---

## 3. Recency weighting

Old games say less about current strength than new ones. Every historical game is weighted by an
exponential decay with a **half-life of 1.5 years**:

```
w(game) = 0.5 ^ (age_in_years / 1.5)
```

A game played yesterday counts ~1.0, a game from mid-2025 counts ~0.6, mid-2023 ~0.25, 2021 ~0.1.
Undated games get a flat weight of 0.5. These weights are used both in the tournament-Elo fit (§4)
and in the head-to-head evidence (§5).

---

## 4. Implied tournament Elo (the core rating)

### Why not just use ladder Elo?

The ladder misleads in both directions. Some players sandbag ladder games (e.g. playing
entertainment matches for content) but show up sharp in tournaments; others farm ladder rating
with one specialist civilization that doesn't survive tournament civ-draft rules. The ladder is
still informative — it is the only signal for players with little tournament history — but it
should be a *starting opinion*, not the verdict.

### The model

Each player gets an **implied tournament Elo** θ, defined as the rating that best explains the
actual tournament results, regularized toward a per-player anchor. Formally, a **Bradley–Terry
network model on the Elo scale** with Gaussian priors:

```
maximize   Σ_games  w_g · ln P(winner_g beats loser_g)   −   Σ_players (θ_i − anchor_i)² / (2σ_i²)

where      P(i beats j) = 1 / (1 + 10^(−(θ_i − θ_j)/400))
```

- `w_g` is the recency weight from §3.
- The fit runs over the **entire game network**, not just roster-vs-roster games. Outsiders act as
  rating bridges: "Psionic beat Kaiserklein, Kaiserklein beat JulianK" is indirect evidence about
  Psionic vs JulianK even if they rarely meet. Only roster players' fitted ratings feed the
  simulator; outsiders are nuisance parameters that carry information.
- Anchors: roster players → their FFP ladder Elo with `σ` = the **ladder anchor** setting
  (default 150, backtest-tuned — see §8b); outsiders with a resolvable FFP profile → their own ladder Elo with a looser
  σ = 250 (inactive ladder ratings decay, so the snapshot can be stale — their network results
  should dominate); outsiders without a profile → a weak default (1900 ± 350), so they're rated
  mostly by their results but can't swing wildly on a handful of games.
- Players with **zero** tournament games sit exactly at their anchor; players with many games are
  rated almost purely by whom they actually beat.
- The optimum is found by damped per-coordinate Newton iteration — with ~230 players and ~3,100
  weighted games it still converges in milliseconds, so the fit is recomputed live in the browser
  whenever the anchor setting changes.

Two properties worth noting:

1. **Opponent strength matters.** Going 13–22 against a murderer's row is judged very differently
   from 13–22 against mid-table opposition — the fit solves for all 24 ratings simultaneously, so
   every win is valued by the fitted strength of the opponent.
2. **It avoids the historical-ladder trap.** An earlier iteration tried to judge each old game
   against the players' ladder Elo *at the time*. That fails: the AoE3 ladder has seasonal resets
   that carve deep artificial valleys into rating histories (a 2400 player shows as 1100 for a few
   weeks every season). The Bradley–Terry fit needs only game outcomes plus today's anchor, so the
   problem disappears.

### Example fits (June 12, 2026 snapshot, anchor σ = 150)

| Player | Ladder | → Tournament | Evidence (full network, incl. games vs outsiders) |
|---|---|---|---|
| Aizamk | 1858 | **2167** (+309) | 72–58 vs strong opposition; rusty ladder rating |
| Hazza | 2279 | **2402** (+123) | 56–20 (74%), incl. his GC2021 run as "Haitch" |
| Psionic | 2123 | **2199** (+76) | 36–33 vs a hard slate, incl. a strong PK Tournament 5 run |
| JulianK | 2437 | **2425** (−12) | 168–76 over 244 games; ladder and tournament play agree |
| Keturboy | 2474 | **2417** (−57) | 104–44 — elite, slightly below what a 2474 rating implies |
| OnlyBaus | 2395 | **2209** (−186) | 53–53 despite a top-4 ladder rating |
| Shake | 2208 | **2098** (−110) | 43–47 in tournaments |

The simulator uses tournament Elo everywhere (a settings toggle reverts to pure ladder Elo for
comparison) as its *pre-tournament baseline*; once EPL 6 results land it conditions this baseline
into a live **Performance Elo** (§4b). The Players table on the site shows both numbers side by
side with each player's underlying record.

---

## 4b. Tournament Elo vs Performance Elo — which number to quote

There are **three** ratings per player, and write-ups and matchup-card bars must use the right one.

| Rating | What it is | Moves during EPL 6? | Where shown |
|---|---|---|---|
| **Ladder Elo** | Raw FFP 1v1 Supremacy rating. Model input / anchor only. | No | "Ladder Elo" column |
| **Tournament Elo** | The Bradley–Terry fit of §4, taken as the **frozen pre-tournament baseline** (`baseTournElo`). The simulator's *starting* strength for each player. | No — fixed the moment EPL 6 begins | "Tourn. Elo" column |
| **Performance Elo** (form Elo) | Tournament Elo **updated by the live conditioning of §7** (`conditionedRatings`) — the posterior-mean strength given every EPL 6 game played so far. The player's *current* estimated strength. | Yes — shifts after each completed series (the `→ … (±form)` deltas) | "Performance" column; the `Performance Elo A → B` line in every card |

**Rule: every per-game win probability, and every per-game win-probability bar in a matchup card,
is computed from the two players' _Performance Elo_ — never the frozen pre-tournament Tournament
Elo, and never Ladder Elo.** The series-level outputs (series win %, expected score, scoreline
distribution, what-if grid) come straight from the simulator, which conditions on live results (§7)
and therefore already runs on Performance-Elo strengths. If a bar is built from Tournament or Ladder
Elo it silently disagrees with its own series prediction — this is exactly the bug that shipped in
the first Grand Finals cards, where a bar showed a player as the per-game underdog while the series
%, expected score, and scoreline distribution on the *same card* all had them favored.

**Why it bites from Week 2 on.** Before any EPL 6 game is played there is nothing to condition on,
so Performance Elo == Tournament Elo and the choice is moot. Once results land, form can move a
player tens of Elo (Kevin +63 after a 5–0; Zoom +40) and the two ratings diverge — precisely when
the wrong pick flips a card.

**How to get it right, and self-check.** Don't hand-roll the Elo logistic from a rating column
(that also ignores the H2H blend of §5). Read the per-game number straight off the simulator's own
series output:

```
per-game win % (first player)  =  expected series score (first player) / N
        N = 7  for Grand Final play-all-7
        N = 5  for round-robin play-all-5
```

Because that value *is* the sim's mean, it is guaranteed consistent with the series % and the
scoreline distribution. **Check every card:** the bar % and `expected_score ÷ N` must agree to the
rounding. If they don't, the bar was computed from the wrong Elo. (Tournament and Ladder Elo may
still be *cited* as colour — "tournament Elo 2451, +12 over ladder" — just never used to derive the
per-game odds once the tournament is underway.)

---

## 5. Head-to-head blending

The fit in §4 captures *overall* tournament strength, but specific matchups have their own history —
some players consistently beat an opponent beyond what the rating gap explains (style matchups,
mental edges). For each scheduled pairing, the per-game win probability blends the rating
prediction with the pair's actual tournament record using a **Beta-binomial posterior**:

```
p_game = (m · p_rating + W_a) / (m + W_a + W_b)
```

- `p_rating` is the Elo logistic on the two tournament Elos.
- `W_a`, `W_b` are the pair's recency-weighted head-to-head game wins (§3).
- `m` (default **0 (off)**) is the prior strength: the rating prediction is treated as
  if it were worth `m` virtual games. Setting `m = 0` disables H2H blending entirely, while higher values blend H2H history.
  The default is deliberately high: held-out testing (§8b) found that pair-specific H2H residuals
  add essentially no predictive value beyond the ratings at these sample sizes — pure ratings
  scored marginally *best* — so the blend is kept only as a light touch.

Example: Ezad vs JulianK have met 12 times in tournaments, 4–8. Ratings alone give Ezad ~22% per
game; his 33% head-to-head rate is actually *better* than the rating gap implies, so the blend
lifts him to ~25%.

One honest caveat: the head-to-head games are also part of the data the tournament Elo was fitted
on, so there is mild double-counting between §4 and §5. The pair-specific *residual* (beating
someone beyond what the ratings explain) is real signal, but the prior weight `m` is kept fairly
high to avoid over-applying the same evidence twice. Both knobs are exposed in the site's settings.

---

## 6. Strength uncertainty

A rating is an estimate, not a fact, and a player's level fluctuates week to week. Each simulated
tournament (one Monte Carlo iteration) samples every player's *effective strength* once:

```
s_p = tournamentElo_p + N(0, σ_u)        (σ_u default 75)
```

The same draw is reused for all of that player's games **within that simulation**, which correlates
their results — a player having a great event wins more across the board, the way form actually
works. This widens the tails of the forecast realistically: without it, 25-game aggregates make
upsets almost impossible; with it, a hot underdog week is merely unlikely.

---

## 7. Monte Carlo simulation

The forecast is the **weighted average of 10,000 simulated tournaments** (seeded PRNG, so a given
data state always reproduces the same numbers; the weights implement the conditioning described
below — with no results played yet, every weight is 1). Each simulation:

1. **Samples strengths** for all 24 players (§6).
2. **Plays the round robin.** For every series:
   - Games already played are **locked at their real scores** (a series is final when its games sum
     to 5; admin rulings that end a series short can be flagged manually in `data.js`).
   - Remaining games are Bernoulli draws at the blended probability (§5), computed from the sampled
     strengths.
3. **Computes standings** — total game wins, then the exact tiebreaker chain from §1.
4. **Plays the Grand Final** between the top 2. Until the admins publish real pairings, each
   finalist is assumed to field its 5 starters **strongest-vs-strongest** (sorted by tournament
   Elo), per the rulebook's pairing custom — this assumption is labeled in the UI and is replaced
   automatically by the real pairings the moment they appear in the sheet. 5 series × play-all-7;
   majority of 35 games wins.
5. **Records** the champion, finalists, each team's final rank and game-win total, and every
   series' score.

Aggregating across iterations yields: P(title), P(reach final), the rank distribution, expected
final game wins with an 80% interval, expected series scores, and the distribution of Grand Final
matchups with per-matchup win splits.

### Live updating

The matches CSV is re-fetched on every page load and re-polled every 60 seconds. When a score
changes in the sheet, the affected games become locked facts, the remaining games are re-simulated,
and all probabilities update in place. Nothing is cached server-side — the forecast can never be
stale by more than a minute while the page is open.

### Conditioning on live results (importance weighting)

Locked games don't just count toward standings — they **condition the whole simulation**, the way
a particle filter would. The pre-tournament ratings are *fixed* the moment the tournament starts;
live results never re-enter the rating fit (that would count the same evidence twice). Instead:

1. Each simulated world draws every player's tournament-form strength `s_p ~ N(rating_p, σ)` as
   usual.
2. Each world is then **weighted by how well it explains the games actually played**:
   `w = Π p^wins · (1−p)^losses` over all observed games, where `p` is the same blended
   probability the simulator itself would assign given that world's strengths.
3. All reported numbers are weight-averaged.

A world where SoldieR's form draw is high explains his 3–2 upset better, so it counts for more —
and since that same form draw drives his *remaining* series, the upset propagates exactly where it
should. This is exact Bayesian conditioning of the form layer: under σ = 75, a single 3–2 upset
shifts the involved players' effective form by ~±20 Elo, a 0–5 shock by ~±55, and an
as-expected result by almost nothing.

Two implementation details for the stats-inclined: strengths are sampled from a **Laplace-style
proposal** (a tiny fit of only the observed games around the fixed ratings) and
importance-corrected back to the prior-centered posterior — this keeps the effective sample size
above ~90% instead of collapsing as results accumulate (the ESS is shown in the page header).
And **what-if pins condition identically to real results**: imagining a scoreline means imagining
the form that produces it. Toggleable in settings ("Condition on live results", default on).

### Odds-over-time chart

Because every series has a scheduled time, the site replays the tournament chronologically:
it computes the forecast as of "before any games", then re-computes after each completed series in
time order. The result is a win-probability timeline that extends itself automatically as results
land. The replay is leak-proof: each historical point conditions (via the importance weighting above)
**only on the games completed up to that moment**, so the "pre-tournament" point genuinely knows
nothing. (This is also why EPL 6's own games are kept out of the scraped head-to-head data rather
than mixed in.)

### What-if mode

Any unfinished series can be pinned to a hypothetical final score. Pinned scores are treated
exactly like locked results — including the conditioning above, so pinning an upset also implies
the form that produces it. The simulation re-runs instantly and the forecast shows deltas against
the baseline. Pins are visual-only and never affect the stored data.

---

## 8. Parameters (all adjustable in the site's settings panel)

| Parameter | Default | Meaning |
|---|---|---|
| Simulations | 10,000 | Monte Carlo iterations per forecast |
| Rating uncertainty σ_u | 75 | Std. dev. of per-simulation strength sampling (§6) — validated by §8b |
| Rating prior vs H2H, `m` | 0 (off) | Virtual games the rating prediction is worth in the blend (§5); 0 disables H2H — tuned in §8b |
| Ladder anchor σ | 150 | Prior width of the tournament-Elo fit (§4); lower trusts the ladder more — tuned in §8b |
| Tournament Elo | on | Off = simulate with raw ladder Elo |
| Condition on live results | on | Importance-weight simulated worlds by how well they explain observed games (§7) |
| Recency half-life | 1.5 years | Decay applied to historical games in both the fit and H2H (§3); slider from 0.5 to 5 years — tuned in §8b |

## 8b. How the defaults were tuned

The four knobs are not taste — each makes a measurable claim, tested with `node predictions/tune.mjs`
(also `npm run tune`):

**Backtest (half-life, anchor σ, m).** Leave-the-future-out cross-validation, blocked by event:
for each of 13 held-out events from 2024 onward (1,505 games), the model is fitted using only
games from strictly earlier events (recency measured at the held-out event's date) and scored by
per-game log loss. Findings, consistent across the full set, a 2025+ subset, and a
roster-players-only subset:

- **Anchor σ:** the June 2026 tune picked 100; the **July 2026 re-tune on the expanded history**
  (+456 games from the category sweep) moved the optimum to **150** on every subset — most
  clearly on roster-vs-roster games (held-out log loss 0.6392 at σ=100 vs 0.6361 at σ=150 vs
  0.6383 at σ=200). More tournament evidence per player means the fit should lean on the ladder
  less. Since today's-ladder anchor leakage biases the tuned σ *low*, the true optimum is if
  anything ≥150.
- **The data expansion itself is the biggest single win:** on a fixed eval set (same 13 held-out
  events), training on the expanded history improves roster-subset log loss from 0.6410 to
  0.6361 — a larger gain than any modeling change tested.
- **Half-life:** a broad plateau from 1–3 years on the expanded data; 1.5 stays.
- **H2H prior m:** "H2H off" still scores best. Matchup residuals are statistically noise at
  these sample sizes. Default 0 (off).
- Reference points: coin-flip log loss 0.693, anchors-only (no tournament games) 0.684, the tuned
  model 0.55–0.58 depending on subset.

**Tested and rejected (July 2026)** — model changes that backtests did *not* support, kept here
so they aren't re-litigated from scratch:

- **Per-player posterior sd in the predictive noise.** The fit's diagonal posterior sd (±37 for
  JulianK, ±100 for a fringe signup) seems like it should widen uncertain players' outcome
  distributions. It doesn't help: in the player-event marginal likelihood, flat σ_u = 75 beats
  every sd-based decomposition (sd + σf = 75 is worse than *no* noise at all). The fitted σ_u
  already absorbs typical rating uncertainty, and the sd's per-player ranking of uncertainty adds
  noise rather than signal. The simulator's flat-σ form sampling stands.
- **Global temperature / marginalized per-game probabilities.** Scaling Elo gaps by T ≈ 1.2
  helps only the roster subset and only by ~0.003 log loss (n = 403 — within noise), and the
  simulator's per-world form sampling already flattens series-level probabilities by the
  equivalent of T ≈ 1.1. Not adopted.
- **Within-series momentum.** A per-series noise term σs ≈ 75 on top of event form improves the
  grouped marginal likelihood by ~2.5 log units over 299 player-events (likelihood-ratio p ≈
  0.03) — probably real (civ/map matchup edges persist within a played-all-5 pairing), but too
  weak to justify complicating the live-conditioning math. Documented, not modeled.

**Form width σ_u.** Estimated separately, since it's about *within-event correlation* rather than
marginal accuracy: for every player-event with ≥4 games, the marginal likelihood of their results
under a persistent form draw δ ~ N(0, σ_u²) is computed by quadrature. The likelihood peaks at
**σ_u = 75** (a 50–100 plateau), and the raw overdispersion check agrees emphatically:
Var(standardized player-event residuals) ≈ 1.9 vs 1.0 for independent games. Form within a
tournament is real, persistent, and about as wide as we assumed — the default σ_u = 75 was
validated rather than changed.

**Honest caveats.** The anchors use *today's* ladder ratings, which partially reflect how past
events went — this leaks information and biases the tuned anchor σ low, which is why evaluation
emphasizes recent events (the 2025+ subset agrees with 100 regardless). And the optima are
plateaus, not points: differences of ±0.002 log loss separate neighboring settings, so the sliders
remain exposed — the data narrows the defensible range, it doesn't end the conversation.
Calibration at the tuned settings is good in the favorites' range and slightly overconfident
against underdogs, which is exactly the gap the per-simulation form noise (σ_u) closes when the
simulator marginalizes over it.

---

## 9. Known limitations

- **Outsider identity & anchoring.** Outsider names are merged by case-insensitive normalization
  and matched to FFP only on exact-name agreement; a misidentified outsider would inject a wrong
  anchor (though the weak default limits the damage). Outsiders' FFP ratings are also
  point-in-time snapshots that may not reflect their strength when the games were played.
- **Double-counting** between the rating fit and the H2H blend, as discussed in §5.
- **No map/civ modeling.** Maps are predetermined and civ drafting matters, but per-map and per-civ
  win rates are not modeled — sample sizes at this pool size don't support it.
- **Grand Final pairings are assumed** (strongest-vs-strongest) until announced.
- **Substitutions**: if a sub plays, the model picks up the name from the live sheet automatically;
  an unknown name falls back to a default rating (1600) and is flagged visibly in the UI.
- **Forfeits** cannot be distinguished from in-progress series in the sheet's data format; they
  require a one-line manual flag (`ADMIN_FINAL` in `data.js`).
- **σ governs how loudly live results speak.** The conditioning in §7 updates the *form layer*,
  whose prior width is σ_u. A wide σ means a single upset moves the forecast a lot; a narrow one
  damps it. That makes σ a consequential modeling choice rather than cosmetic smoothing — the
  effective sample size in the page header shows how concentrated the conditioning currently is.
- Ratings snapshots are manual: re-run `fetch-data.mjs` (roster ladder), `fetch-history.mjs`
  (tournaments) and `fetch-outsiders.mjs` (outsider anchors) to refresh — or `npm run refresh`
  for all three plus the test suite. Live match results need no refresh.

---

## 10. Reproducibility

Everything is plain JavaScript with no build step:

- `sim.js` — the pure engine: Bradley–Terry network fit, H2H weighting, blending, the
  importance-weighted Monte Carlo (proposal sampling, log-weights, weighted statistics and
  quantiles, ESS), and the exact tiebreakers. Importable from Node and the browser alike.
- `test-sim.mjs` — 54 sanity checks (`node predictions/test-sim.mjs`): symmetric models give
  symmetric odds, locked results reproduce deterministic standings, the fit recovers known
  synthetic cases (a "ladder thrower" beating a giant gets pulled up; outsider chains transfer
  strength; stale games move ratings less; tighter anchors stay nearer the ladder), conditioning
  lifts an upset-winner's future series while what-if pins behave identically to locked results,
  and the same seed always yields identical output — weighted or not.
- `fetch-data.mjs` / `fetch-history.mjs` / `fetch-outsiders.mjs` — the snapshot scripts, with the
  name-alias maps, corrections and exclusion lists as the single source of truth for identity
  joins; `data/manual-games.json` for hand-entered results.

Tournament history © Liquipedia contributors, [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/).
Predictions are for fun, not betting advice.
