Skip to content

Commit fc62469

Browse files
valvesssclaude
andcommitted
feat(retro): /retro retrospective post — first verdicts vs the frozen ledger
A live editorial post (and docs/RETROSPECTIVE.md): what the pre-registered predictions got right, wrong, and implicitly knew across the first Round-of-32 games. Stat row + per-game table pull live from the ledger so it updates as more games resolve; the analysis is written for the opening round. Linked from the /worldcup retrospective section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 59e89ee commit fc62469

3 files changed

Lines changed: 228 additions & 1 deletion

File tree

site/src/pages/retro.astro

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
---
2+
import Base from '../layouts/Base.astro';
3+
import Footer from '../components/Footer.astro';
4+
import wcl from '../data/wc_live.json';
5+
6+
const R = wcl.resolved_detail || [];
7+
const n = R.length;
8+
const favAdv = R.filter(r => r.advanced === r.fav_team).length;
9+
const nPens = R.filter(r => r.pens).length;
10+
const favBet = R.filter(r => r.fav_won).length;
11+
// advance Brier (favourite is the home side in these fixtures → adv_home)
12+
const brier = n ? R.reduce((s, r) => s + Math.pow((r.adv_home ?? 0) - (r.advanced === r.fav_team ? 1 : 0), 2), 0) / n : 0;
13+
// total-goals MAE (xG sum vs actual)
14+
const totGoals = (s) => s ? s.split('-').reduce((a, b) => a + (+b), 0) : null;
15+
const mae = n ? R.reduce((s, r) => s + Math.abs(((r.xg_home ?? 0) + (r.xg_away ?? 0)) - (totGoals(r.score) ?? 0)), 0) / n : 0;
16+
const pct = (x) => Math.round(x * 100) + '%';
17+
---
18+
<Base title="Round of 32 — first verdicts | skew-dynamic"
19+
description="The pre-registered World Cup ledger meets reality: what our frozen predictions got right, wrong, and implicitly knew across the opening knockout games."
20+
path="/retro">
21+
<header class="r-top">
22+
<div class="container r-top-inner">
23+
<a class="brand" href="/"><span class="brand-mark"></span>skew<span class="brand-dim">-dynamic</span></a>
24+
<div class="r-top-links">
25+
<a href="/worldcup">World Cup</a><a href="/goals">Goals</a>
26+
<a href="https://github.com/cold-code-labs/skew-dynamic" target="_blank" rel="noopener">GitHub ↗</a>
27+
</div>
28+
</div>
29+
</header>
30+
31+
<article class="container r-wrap">
32+
<div class="eyebrow reveal">● Live retrospective · pre-registered ledger vs reality</div>
33+
<h1 class="reveal">Round of 32 — the first verdicts.</h1>
34+
<p class="r-lede reveal">On 2026-06-28, before a ball was kicked, the model froze a prediction for every
35+
knockout fixture — favourite, win probability, skewness, expected goals, chance to advance, even the penalty
36+
split. Here is what the first {n} games did to those frozen calls. The point was never to tip winners; it
37+
was to <strong>price the shape of the risk</strong>. On that, the early read holds up better than the
38+
scoreline suggests.</p>
39+
40+
<div class="r-stats reveal">
41+
<div><b>{favAdv}/{n}</b><span>favourites advanced<br/>(we predicted ~{pct(0.70)})</span></div>
42+
<div><b>{nPens}/{n}</b><span>went to penalties<br/>favourites lost both</span></div>
43+
<div><b>{mae.toFixed(2)}</b><span>goals — total-goals<br/>forecast error</span></div>
44+
<div><b>{brier.toFixed(3)}</b><span>advance Brier<br/>(0.25 = coin-flip)</span></div>
45+
</div>
46+
47+
<div class="r-games reveal">
48+
{R.map(r => <div class={`r-game ${r.fav_won ? 'ok' : 'no'}`}>
49+
<div class="r-game-top">
50+
<div class="r-side">
51+
{r.home_flag && <img src={`https://flagcdn.com/w40/${r.home_flag}.png`} alt="" width="30" height="20" />}
52+
<b class={r.advanced===r.home ? 'adv' : ''}>{r.home}</b>
53+
</div>
54+
<div class="r-sc">{r.score}{r.pens && <i>pens {r.pens_score}</i>}</div>
55+
<div class="r-side r-r">
56+
<b class={r.advanced===r.away ? 'adv' : ''}>{r.away}</b>
57+
{r.away_flag && <img src={`https://flagcdn.com/w40/${r.away_flag}.png`} alt="" width="30" height="20" />}
58+
</div>
59+
</div>
60+
<div class="r-game-bot">
61+
<span>frozen: <b>{r.fav_team} {pct(r.p_fav)}</b> · advance {pct(r.adv_home)} · skew {r.skew_pred>=0?'+':''}{r.skew_pred.toFixed(2)} · ~{r.score_pred}</span>
62+
<span class={`r-tag ${r.fav_won ? 'ok' : 'no'}`}>{r.fav_won ? '✓ favourite won' : (r.pens ? '✗ out on penalties' : '✗ upset')}</span>
63+
</div>
64+
</div>)}
65+
</div>
66+
67+
<section class="r-body reveal">
68+
<h2>What we got right — and implicitly knew</h2>
69+
<p><b>The upset alert was already on the board.</b> Netherlands × Morocco was the <em>only</em> game we
70+
flagged as a near coin-flip: favourite probability <b>48%</b>, skewness <b>+0.08</b> (essentially
71+
symmetric → "this favourite bet behaves like a mild longshot"), chance to advance just <b>63%</b>. The
72+
model said <em>"this is the toss-up"</em> — and it went to penalties, Morocco through. We priced the upset
73+
risk without naming the winner.</p>
74+
<blockquote>Skewness was never a tip on who wins — it is the <em>shape</em> of the bet's return. Germany was
75+
our most left-skewed bet (−0.51: "wins small, rarely loses big"). The rare, ugly loss is exactly what a
76+
left tail encodes — and the tail hit.</blockquote>
77+
<p>The point prediction (Germany advances) missed, but the <b>risk characterisation was right</b>: a heavy
78+
favourite fragile to a catastrophic tail event. And the goals model called the texture cleanly — we
79+
predicted ~1–0 in all three, and all three finished low and were decided by a single goal in regulation
80+
(<b>total-goals error {mae.toFixed(2)}</b>).</p>
81+
82+
<h2>What we got wrong</h2>
83+
<p><b>Germany 77% to advance</b> — our most confident call, the biggest upset. The Elo overrated Germany
84+
("brand" weight + the dataset's group form), echoing its real modern-tournament fragility. A structural
85+
miss, not bad luck. Overall it was a <b>bad day for favourites</b> (advance Brier {brier.toFixed(3)} vs
86+
0.25 baseline) — but two of three were penalty lotteries we modelled as ~50/50, so much of the "miss" is
87+
just knockout variance. <b>Shootouts 0/2:</b> we gave the favourite a slight edge and both lost. We got
88+
the humility right (shrunk toward a coin-flip, in print) — the skill signal was on the wrong side twice.</p>
89+
90+
<h2>What to improve</h2>
91+
<ul>
92+
<li><b>Recent-form weighting</b> — so fragile "brands" like Germany aren't overrated by raw Elo.</li>
93+
<li><b>Penalties = an exact coin-flip</b> (or a real keeper/history model) — the slight skill edge was wrong twice.</li>
94+
<li><b>Model extra-time goals explicitly</b> instead of folding extra time into the shootout coin.</li>
95+
<li><b>Calibrate advance probabilities</b> on historical knockouts — 77%/63% may be over-confident.</li>
96+
</ul>
97+
98+
<h2>The meta-point</h2>
99+
<p>This is the first real test of the <a href="https://github.com/cold-code-labs/skew-dynamic/blob/main/site/src/data/wc_predictions.json" target="_blank" rel="noopener">pre-registered ledger</a>:
100+
predictions frozen before kickoff, now checked against reality — integrity intact. With n = {n} (and two
101+
penalty lotteries) it is far too early to judge calibration; the scorecard becomes evidence at n = 16, the
102+
full Round of 32. But the direction is on-thesis: <b>the toss-ups went to penalties, and the skewness
103+
measured the shape of the risk correctly even when the winner surprised.</b> The bet's character was
104+
right; the coin landed where coins land.</p>
105+
<p class="r-foot">Sources: ESPN, FIFA, CBS Sports, Yahoo Sports (match reports, 2026-06-29) ·
106+
full method in <a href="/worldcup">the World Cup annex</a> · the goals model in <a href="/goals">the over/under ladder</a>.</p>
107+
</section>
108+
</article>
109+
110+
<Footer />
111+
</Base>
112+
113+
<style>
114+
.r-top { position: sticky; top: 0; z-index: 40; backdrop-filter: saturate(160%) blur(12px); background: rgba(252,253,255,.82); border-bottom: 1px solid var(--line-soft); }
115+
.r-top-inner { display: flex; align-items: center; height: 60px; }
116+
.r-top-links { margin-left: auto; display: flex; gap: 18px; }
117+
.r-top-links a { color: var(--ink-soft); font-size: .9rem; font-weight: 500; }
118+
.r-top-links a:hover { color: var(--accent); text-decoration: none; }
119+
.brand { font-weight: 650; letter-spacing: -.02em; color: var(--ink); display: flex; align-items: center; gap: 9px; }
120+
.brand:hover { text-decoration: none; } .brand-dim { color: var(--ink-faint); font-weight: 500; }
121+
.brand-mark { width: 16px; height: 16px; border-radius: 5px; background: linear-gradient(135deg, var(--accent), var(--navy)); box-shadow: 0 0 0 3px var(--accent-soft); }
122+
123+
.r-wrap { max-width: 760px; padding-top: 56px; padding-bottom: 40px; }
124+
.r-wrap h1 { font-size: clamp(2rem, 5vw, 3rem); letter-spacing: -.03em; line-height: 1.05; margin: 14px 0 18px; }
125+
.r-lede { font-size: 1.15rem; color: var(--ink-soft); line-height: 1.6; }
126+
.r-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin: 32px 0; }
127+
@media (min-width: 640px) { .r-stats { grid-template-columns: repeat(4, 1fr); } }
128+
.r-stats b { font-family: var(--mono); font-size: 1.9rem; font-weight: 650; color: var(--accent); display: block; }
129+
.r-stats span { font-size: .76rem; color: var(--ink-faint); line-height: 1.3; }
130+
131+
.r-games { display: flex; flex-direction: column; gap: 12px; margin: 28px 0; }
132+
.r-game { border: 1px solid var(--line-soft); border-left: 3px solid var(--line); border-radius: 12px; padding: 14px 18px; }
133+
.r-game.ok { border-left-color: var(--pos); } .r-game.no { border-left-color: var(--neg); }
134+
.r-game-top { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; }
135+
.r-side { display: flex; align-items: center; gap: 9px; font-size: 1.1rem; min-width: 0; }
136+
.r-side b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
137+
.r-side b.adv { color: var(--accent); } .r-r { justify-content: flex-end; }
138+
.r-side img { border-radius: 3px; flex: 0 0 auto; box-shadow: 0 1px 2px rgba(0,0,0,.15); }
139+
.r-sc { font-family: var(--mono); font-weight: 700; font-size: 1.35rem; text-align: center; white-space: nowrap; }
140+
.r-sc i { display: block; font-size: .62rem; font-weight: 500; color: var(--ink-faint); font-style: normal; }
141+
.r-game-bot { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--line-soft); flex-wrap: wrap; }
142+
.r-game-bot > span:first-child { font-size: .8rem; color: var(--ink-soft); } .r-game-bot b { color: var(--ink); }
143+
.r-tag { font-size: .8rem; font-weight: 600; } .r-tag.ok { color: var(--pos); } .r-tag.no { color: var(--neg); }
144+
145+
.r-body h2 { font-size: 1.4rem; letter-spacing: -.02em; margin: 36px 0 12px; }
146+
.r-body p { color: var(--ink-soft); line-height: 1.7; margin-bottom: 14px; }
147+
.r-body b { color: var(--ink); }
148+
.r-body blockquote { margin: 20px 0; padding: 14px 20px; border-left: 3px solid var(--accent); background: var(--accent-soft); border-radius: 0 8px 8px 0; font-size: 1.05rem; color: var(--ink); line-height: 1.6; }
149+
.r-body ul { color: var(--ink-soft); line-height: 1.7; padding-left: 22px; margin-bottom: 14px; }
150+
.r-body li { margin-bottom: 6px; }
151+
.r-foot { font-size: .82rem; color: var(--ink-faint); margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--line-soft); }
152+
</style>

site/src/pages/worldcup.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ const pct = (x) => (x * 100).toFixed(0) + '%';
173173
</div>)}
174174
<p class="note">The favourite-bet skewness is a <em>shape</em> call, not a tip: Germany was our most left-skewed
175175
bet (−0.51, "wins small, rarely loses big") — and the rare big loss is exactly what happened. Netherlands
176-
was our only near-symmetric toss-up (+0.08) — and it went to penalties. Full breakdown below.</p>
176+
was our only near-symmetric toss-up (+0.08) — and it went to penalties. <a href="/retro">Read the full breakdown →</a></p>
177177
</div>}
178178

179179
{wcl.recent_games && wcl.recent_games.length > 0 && <div class="nm-results reveal">

study/docs/RETROSPECTIVE.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Retrospective — Round of 32, the first verdicts
2+
3+
*Companion to the [World Cup annex](WORLDCUP.md). The pre-registered ledger
4+
(`site/src/data/wc_predictions.json`) froze a prediction for every knockout fixture
5+
on 2026-06-28 — favourite, win probability, skewness, expected goals, chance to
6+
advance, shootout split — before a ball was kicked. Here we check the first three
7+
against reality.*
8+
9+
## The scoreboard
10+
11+
| Match | Result | How it resolved | Our frozen call |
12+
|---|---|---|---|
13+
| 🇧🇷 Brazil **2–1** Japan 🇯🇵 | Brazil won | Martinelli 96', in regulation | favourite Brazil 56% · advance 71% · ~1–0 |
14+
| 🇩🇪 Germany **1–1** Paraguay 🇵🇾 | **Paraguay 4–3 pens** | Germany out — biggest upset | favourite Germany 62% · advance 77% · ~1–0 |
15+
| 🇳🇱 Netherlands **1–1** Morocco 🇲🇦 | **Morocco 3–2 pens** | Netherlands out | favourite Netherlands 48% · advance 63% · ~1–0 |
16+
17+
**Favourites advanced 1/3 · two of three went to penalties · favourites lost both
18+
shootouts (0/2) · the favourite 1X2 bet went 1W–2L.**
19+
20+
## What we got right — and implicitly knew
21+
22+
**1. The upset alert was already on the board — Netherlands × Morocco.** It was the
23+
*only* game we flagged as a near coin-flip: favourite probability **48%**, skewness
24+
**+0.08** (essentially symmetric → "this favourite bet behaves like a mild
25+
longshot"), chance to advance just **63%**. The model literally said *"this is the
26+
toss-up"* — and it went to penalties, Morocco through. We priced the upset risk
27+
without naming the winner.
28+
29+
**2. Skewness vindicated itself as a risk-*shape* read — the deepest point.**
30+
Skewness was never a tip on who wins; it is the *shape* of the bet's return. Germany
31+
was our **most left-skewed** bet (**−0.51** = "wins small, rarely loses **big**").
32+
The rare, ugly loss — elimination — is exactly what a left tail encodes. The point
33+
prediction (Germany advances) missed, but the **risk characterisation was right**: a
34+
heavy favourite fragile to a catastrophic tail event, and the tail hit.
35+
36+
**3. Tight, low-scoring games — called cleanly.** We predicted ~1–0 scorelines and
37+
low expected goals in all three; all three finished low and were decided by a single
38+
goal in regulation. **Mean error on total goals: 0.34** (xG sums 2.38 / 2.21 / 1.80
39+
vs actual 3 / 2 / 2). The goals model knew these would be cagey.
40+
41+
## What we got wrong
42+
43+
**1. Germany 77% to advance — our most confident call, the biggest upset.** The Elo
44+
overrated Germany ("brand" weight + the dataset's group form), echoing Germany's real
45+
modern-tournament fragility (2018/2022 exits). This was a structural miss, not bad
46+
luck.
47+
48+
**2. A bad day for favourites: advance Brier 0.358** (worse than the 0.25 baseline).
49+
But two of three were **penalty lotteries** we modelled as ~50/50 — so part of the
50+
"miss" is just knockout variance, not miscalibration.
51+
52+
**3. Shootouts 0/2.** We gave the favourite a slight edge (56–62%) and both lost. We
53+
got the *humility* right (we shrank toward a coin-flip and said so in print), but the
54+
skill signal was on the wrong side twice.
55+
56+
## What to improve
57+
58+
| Problem | Fix |
59+
|---|---|
60+
| Elo overrates fragile "brands" (Germany) | weight **recent form / tournament under-performance** |
61+
| Shootout skill edge wrong 2/2 | penalties = **exact coin-flip** (or model keeper / history) |
62+
| "Advance" folds extra-time into the shootout coin | model **extra-time goals** explicitly |
63+
| Advance 77% / 63% possibly over-confident | **calibrate** advance probabilities on historical knockouts |
64+
65+
## The meta-point
66+
67+
This is the **first real test of the pre-registered ledger**: predictions frozen on
68+
2026-06-28, before kickoff, now checked against reality — integrity intact. With
69+
**n = 3** (and two penalty lotteries) it is far too early to judge calibration; the
70+
scorecard becomes evidence at **n = 16** (the full Round of 32). But the *direction*
71+
is on-thesis: **the toss-ups went to penalties, and the skewness measured the shape
72+
of the risk correctly even when the winner surprised.** The bet's character was
73+
right; the coin landed where coins land.
74+
75+
*Sources: ESPN, FIFA, CBS Sports, Yahoo Sports (match reports, 2026-06-29).*

0 commit comments

Comments
 (0)