Moftah commited on
Commit
de509df
·
1 Parent(s): 3b061a6

Upgrade World Cup app with live knockout predictions

Browse files
.github/workflows/result-check.yml CHANGED
@@ -51,14 +51,23 @@ jobs:
51
  run: |
52
  echo "result_provider=${{ steps.fetch.outputs.provider }}" >> "$GITHUB_STEP_SUMMARY"
53
  echo "result_changes=${{ steps.fetch.outputs.changed }}" >> "$GITHUB_STEP_SUMMARY"
 
 
 
 
 
 
 
 
 
 
54
  - name: Apply audited update
55
  if: steps.fetch.outputs.changed == 'true'
56
  run: |
57
  PYTHONPATH=src python scripts/update_snapshot.py \
58
- --results-file "${{ steps.fetch.outputs.output }}" \
59
- --allow-corrections
60
  - name: Verify candidate snapshot
61
- if: steps.fetch.outputs.changed == 'true'
62
  run: |
63
  PYTHONPATH=src python scripts/health_check.py --ignore-overdue-results
64
  PYTHONPATH=src:. pytest
@@ -69,12 +78,13 @@ jobs:
69
  python - <<'PY'
70
  import json, os
71
  path = os.environ["UPDATE_PATH"]
72
- data = json.load(open(path))
73
  with open("/tmp/result-pr.md", "w") as out:
74
- out.write("Automated football-data.org result update.\n\n")
75
  out.write(f"- Additions: {len(data.get('results', []))}\n")
76
  out.write(f"- Corrections requiring review: {len(data.get('corrections', []))}\n")
77
- out.write(f"- Raw response SHA-256: `{data['raw_response_sha256']}`\n")
 
78
  for row in data.get("results", []):
79
  out.write(f"- Add `{row['fixture_id']}`: {row['home']} {row['home_goals']}-{row['away_goals']} {row['away']}\n")
80
  for row in data.get("corrections", []):
@@ -83,7 +93,7 @@ jobs:
83
  env:
84
  UPDATE_PATH: ${{ steps.fetch.outputs.output }}
85
  - name: Open or update result PR
86
- if: steps.fetch.outputs.changed == 'true'
87
  uses: peter-evans/create-pull-request@v8
88
  with:
89
  branch: automation/wc2026-results
@@ -96,3 +106,4 @@ jobs:
96
  data/world_cup_2026/snapshot.json
97
  data/world_cup_2026/backups/*.json
98
  data/world_cup_2026/result_updates/*.json
 
 
51
  run: |
52
  echo "result_provider=${{ steps.fetch.outputs.provider }}" >> "$GITHUB_STEP_SUMMARY"
53
  echo "result_changes=${{ steps.fetch.outputs.changed }}" >> "$GITHUB_STEP_SUMMARY"
54
+ - name: Refresh knockout bracket
55
+ id: knockout
56
+ run: |
57
+ PYTHONPATH=src python scripts/build_knockout_snapshot.py --output /tmp/knockout.json
58
+ if [ ! -f data/world_cup_2026/knockout.json ] || ! cmp -s <(jq -S '.matches' data/world_cup_2026/knockout.json) <(jq -S '.matches' /tmp/knockout.json); then
59
+ cp /tmp/knockout.json data/world_cup_2026/knockout.json
60
+ echo "changed=true" >> "$GITHUB_OUTPUT"
61
+ else
62
+ echo "changed=false" >> "$GITHUB_OUTPUT"
63
+ fi
64
  - name: Apply audited update
65
  if: steps.fetch.outputs.changed == 'true'
66
  run: |
67
  PYTHONPATH=src python scripts/update_snapshot.py \
68
+ --results-file "${{ steps.fetch.outputs.output }}"
 
69
  - name: Verify candidate snapshot
70
+ if: steps.fetch.outputs.changed == 'true' || steps.knockout.outputs.changed == 'true'
71
  run: |
72
  PYTHONPATH=src python scripts/health_check.py --ignore-overdue-results
73
  PYTHONPATH=src:. pytest
 
78
  python - <<'PY'
79
  import json, os
80
  path = os.environ["UPDATE_PATH"]
81
+ data = json.load(open(path)) if path and os.path.exists(path) else {}
82
  with open("/tmp/result-pr.md", "w") as out:
83
+ out.write("Automated World Cup result and bracket update.\n\n")
84
  out.write(f"- Additions: {len(data.get('results', []))}\n")
85
  out.write(f"- Corrections requiring review: {len(data.get('corrections', []))}\n")
86
+ if data.get("raw_response_sha256"):
87
+ out.write(f"- Raw response SHA-256: `{data['raw_response_sha256']}`\n")
88
  for row in data.get("results", []):
89
  out.write(f"- Add `{row['fixture_id']}`: {row['home']} {row['home_goals']}-{row['away_goals']} {row['away']}\n")
90
  for row in data.get("corrections", []):
 
93
  env:
94
  UPDATE_PATH: ${{ steps.fetch.outputs.output }}
95
  - name: Open or update result PR
96
+ if: steps.fetch.outputs.changed == 'true' || steps.knockout.outputs.changed == 'true'
97
  uses: peter-evans/create-pull-request@v8
98
  with:
99
  branch: automation/wc2026-results
 
106
  data/world_cup_2026/snapshot.json
107
  data/world_cup_2026/backups/*.json
108
  data/world_cup_2026/result_updates/*.json
109
+ data/world_cup_2026/knockout.json
data/world_cup_2026/knockout.json ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": "knockout-v1",
3
+ "provider": "espn",
4
+ "fetched_at": "2026-07-15T08:20:39.965447+00:00",
5
+ "raw_response_sha256": "9e17cb0dc49d288141a75ff811230eed51726f60198351418989305596097cbc",
6
+ "matches": [
7
+ {
8
+ "fixture_id": "WC26-073",
9
+ "match_number": 73,
10
+ "stage": "round_of_32",
11
+ "date": "2026-06-28",
12
+ "kickoff_utc": "2026-06-28T19:00:00+00:00",
13
+ "home": "South Africa",
14
+ "away": "Canada",
15
+ "home_goals": 0,
16
+ "away_goals": 1,
17
+ "winner": "Canada",
18
+ "provider_match_id": "760486",
19
+ "provider_status": "STATUS_FULL_TIME"
20
+ },
21
+ {
22
+ "fixture_id": "WC26-074",
23
+ "match_number": 74,
24
+ "stage": "round_of_32",
25
+ "date": "2026-06-29",
26
+ "kickoff_utc": "2026-06-29T17:00:00+00:00",
27
+ "home": "Brazil",
28
+ "away": "Japan",
29
+ "home_goals": 2,
30
+ "away_goals": 1,
31
+ "winner": "Brazil",
32
+ "provider_match_id": "760487",
33
+ "provider_status": "STATUS_FULL_TIME"
34
+ },
35
+ {
36
+ "fixture_id": "WC26-075",
37
+ "match_number": 75,
38
+ "stage": "round_of_32",
39
+ "date": "2026-06-29",
40
+ "kickoff_utc": "2026-06-29T20:30:00+00:00",
41
+ "home": "Germany",
42
+ "away": "Paraguay",
43
+ "home_goals": 1,
44
+ "away_goals": 1,
45
+ "winner": "Paraguay",
46
+ "provider_match_id": "760489",
47
+ "provider_status": "STATUS_FINAL_PEN"
48
+ },
49
+ {
50
+ "fixture_id": "WC26-076",
51
+ "match_number": 76,
52
+ "stage": "round_of_32",
53
+ "date": "2026-06-30",
54
+ "kickoff_utc": "2026-06-30T01:00:00+00:00",
55
+ "home": "Netherlands",
56
+ "away": "Morocco",
57
+ "home_goals": 1,
58
+ "away_goals": 1,
59
+ "winner": "Morocco",
60
+ "provider_match_id": "760488",
61
+ "provider_status": "STATUS_FINAL_PEN"
62
+ },
63
+ {
64
+ "fixture_id": "WC26-077",
65
+ "match_number": 77,
66
+ "stage": "round_of_32",
67
+ "date": "2026-06-30",
68
+ "kickoff_utc": "2026-06-30T17:00:00+00:00",
69
+ "home": "Ivory Coast",
70
+ "away": "Norway",
71
+ "home_goals": 1,
72
+ "away_goals": 2,
73
+ "winner": "Norway",
74
+ "provider_match_id": "760490",
75
+ "provider_status": "STATUS_FULL_TIME"
76
+ },
77
+ {
78
+ "fixture_id": "WC26-078",
79
+ "match_number": 78,
80
+ "stage": "round_of_32",
81
+ "date": "2026-06-30",
82
+ "kickoff_utc": "2026-06-30T21:00:00+00:00",
83
+ "home": "France",
84
+ "away": "Sweden",
85
+ "home_goals": 3,
86
+ "away_goals": 0,
87
+ "winner": "France",
88
+ "provider_match_id": "760492",
89
+ "provider_status": "STATUS_FULL_TIME"
90
+ },
91
+ {
92
+ "fixture_id": "WC26-079",
93
+ "match_number": 79,
94
+ "stage": "round_of_32",
95
+ "date": "2026-07-01",
96
+ "kickoff_utc": "2026-07-01T02:00:00+00:00",
97
+ "home": "Mexico",
98
+ "away": "Ecuador",
99
+ "home_goals": 2,
100
+ "away_goals": 0,
101
+ "winner": "Mexico",
102
+ "provider_match_id": "760491",
103
+ "provider_status": "STATUS_FULL_TIME"
104
+ },
105
+ {
106
+ "fixture_id": "WC26-080",
107
+ "match_number": 80,
108
+ "stage": "round_of_32",
109
+ "date": "2026-07-01",
110
+ "kickoff_utc": "2026-07-01T16:00:00+00:00",
111
+ "home": "England",
112
+ "away": "DR Congo",
113
+ "home_goals": 2,
114
+ "away_goals": 1,
115
+ "winner": "England",
116
+ "provider_match_id": "760495",
117
+ "provider_status": "STATUS_FULL_TIME"
118
+ },
119
+ {
120
+ "fixture_id": "WC26-081",
121
+ "match_number": 81,
122
+ "stage": "round_of_32",
123
+ "date": "2026-07-01",
124
+ "kickoff_utc": "2026-07-01T20:00:00+00:00",
125
+ "home": "Belgium",
126
+ "away": "Senegal",
127
+ "home_goals": 3,
128
+ "away_goals": 2,
129
+ "winner": "Belgium",
130
+ "provider_match_id": "760493",
131
+ "provider_status": "STATUS_FINAL_AET"
132
+ },
133
+ {
134
+ "fixture_id": "WC26-082",
135
+ "match_number": 82,
136
+ "stage": "round_of_32",
137
+ "date": "2026-07-02",
138
+ "kickoff_utc": "2026-07-02T00:00:00+00:00",
139
+ "home": "United States",
140
+ "away": "Bosnia and Herzegovina",
141
+ "home_goals": 2,
142
+ "away_goals": 0,
143
+ "winner": "United States",
144
+ "provider_match_id": "760494",
145
+ "provider_status": "STATUS_FULL_TIME"
146
+ },
147
+ {
148
+ "fixture_id": "WC26-083",
149
+ "match_number": 83,
150
+ "stage": "round_of_32",
151
+ "date": "2026-07-02",
152
+ "kickoff_utc": "2026-07-02T19:00:00+00:00",
153
+ "home": "Spain",
154
+ "away": "Austria",
155
+ "home_goals": 3,
156
+ "away_goals": 0,
157
+ "winner": "Spain",
158
+ "provider_match_id": "760497",
159
+ "provider_status": "STATUS_FULL_TIME"
160
+ },
161
+ {
162
+ "fixture_id": "WC26-084",
163
+ "match_number": 84,
164
+ "stage": "round_of_32",
165
+ "date": "2026-07-02",
166
+ "kickoff_utc": "2026-07-02T23:00:00+00:00",
167
+ "home": "Portugal",
168
+ "away": "Croatia",
169
+ "home_goals": 2,
170
+ "away_goals": 1,
171
+ "winner": "Portugal",
172
+ "provider_match_id": "760496",
173
+ "provider_status": "STATUS_FULL_TIME"
174
+ },
175
+ {
176
+ "fixture_id": "WC26-085",
177
+ "match_number": 85,
178
+ "stage": "round_of_32",
179
+ "date": "2026-07-03",
180
+ "kickoff_utc": "2026-07-03T03:00:00+00:00",
181
+ "home": "Switzerland",
182
+ "away": "Algeria",
183
+ "home_goals": 2,
184
+ "away_goals": 0,
185
+ "winner": "Switzerland",
186
+ "provider_match_id": "760498",
187
+ "provider_status": "STATUS_FULL_TIME"
188
+ },
189
+ {
190
+ "fixture_id": "WC26-086",
191
+ "match_number": 86,
192
+ "stage": "round_of_32",
193
+ "date": "2026-07-03",
194
+ "kickoff_utc": "2026-07-03T18:00:00+00:00",
195
+ "home": "Australia",
196
+ "away": "Egypt",
197
+ "home_goals": 1,
198
+ "away_goals": 1,
199
+ "winner": "Egypt",
200
+ "provider_match_id": "760499",
201
+ "provider_status": "STATUS_FINAL_PEN"
202
+ },
203
+ {
204
+ "fixture_id": "WC26-087",
205
+ "match_number": 87,
206
+ "stage": "round_of_32",
207
+ "date": "2026-07-03",
208
+ "kickoff_utc": "2026-07-03T22:00:00+00:00",
209
+ "home": "Argentina",
210
+ "away": "Cape Verde",
211
+ "home_goals": 3,
212
+ "away_goals": 2,
213
+ "winner": "Argentina",
214
+ "provider_match_id": "760500",
215
+ "provider_status": "STATUS_FINAL_AET"
216
+ },
217
+ {
218
+ "fixture_id": "WC26-088",
219
+ "match_number": 88,
220
+ "stage": "round_of_32",
221
+ "date": "2026-07-04",
222
+ "kickoff_utc": "2026-07-04T01:30:00+00:00",
223
+ "home": "Colombia",
224
+ "away": "Ghana",
225
+ "home_goals": 1,
226
+ "away_goals": 0,
227
+ "winner": "Colombia",
228
+ "provider_match_id": "760501",
229
+ "provider_status": "STATUS_FULL_TIME"
230
+ },
231
+ {
232
+ "fixture_id": "WC26-089",
233
+ "match_number": 89,
234
+ "stage": "round_of_16",
235
+ "date": "2026-07-04",
236
+ "kickoff_utc": "2026-07-04T17:00:00+00:00",
237
+ "home": "Canada",
238
+ "away": "Morocco",
239
+ "home_goals": 0,
240
+ "away_goals": 3,
241
+ "winner": "Morocco",
242
+ "provider_match_id": "760502",
243
+ "provider_status": "STATUS_FULL_TIME"
244
+ },
245
+ {
246
+ "fixture_id": "WC26-090",
247
+ "match_number": 90,
248
+ "stage": "round_of_16",
249
+ "date": "2026-07-04",
250
+ "kickoff_utc": "2026-07-04T21:00:00+00:00",
251
+ "home": "Paraguay",
252
+ "away": "France",
253
+ "home_goals": 0,
254
+ "away_goals": 1,
255
+ "winner": "France",
256
+ "provider_match_id": "760503",
257
+ "provider_status": "STATUS_FULL_TIME"
258
+ },
259
+ {
260
+ "fixture_id": "WC26-091",
261
+ "match_number": 91,
262
+ "stage": "round_of_16",
263
+ "date": "2026-07-05",
264
+ "kickoff_utc": "2026-07-05T20:00:00+00:00",
265
+ "home": "Brazil",
266
+ "away": "Norway",
267
+ "home_goals": 1,
268
+ "away_goals": 2,
269
+ "winner": "Norway",
270
+ "provider_match_id": "760504",
271
+ "provider_status": "STATUS_FULL_TIME"
272
+ },
273
+ {
274
+ "fixture_id": "WC26-092",
275
+ "match_number": 92,
276
+ "stage": "round_of_16",
277
+ "date": "2026-07-06",
278
+ "kickoff_utc": "2026-07-06T01:00:00+00:00",
279
+ "home": "Mexico",
280
+ "away": "England",
281
+ "home_goals": 2,
282
+ "away_goals": 3,
283
+ "winner": "England",
284
+ "provider_match_id": "760505",
285
+ "provider_status": "STATUS_FULL_TIME"
286
+ },
287
+ {
288
+ "fixture_id": "WC26-093",
289
+ "match_number": 93,
290
+ "stage": "round_of_16",
291
+ "date": "2026-07-06",
292
+ "kickoff_utc": "2026-07-06T19:00:00+00:00",
293
+ "home": "Portugal",
294
+ "away": "Spain",
295
+ "home_goals": 0,
296
+ "away_goals": 1,
297
+ "winner": "Spain",
298
+ "provider_match_id": "760506",
299
+ "provider_status": "STATUS_FULL_TIME"
300
+ },
301
+ {
302
+ "fixture_id": "WC26-094",
303
+ "match_number": 94,
304
+ "stage": "round_of_16",
305
+ "date": "2026-07-07",
306
+ "kickoff_utc": "2026-07-07T00:00:00+00:00",
307
+ "home": "United States",
308
+ "away": "Belgium",
309
+ "home_goals": 1,
310
+ "away_goals": 4,
311
+ "winner": "Belgium",
312
+ "provider_match_id": "760507",
313
+ "provider_status": "STATUS_FULL_TIME"
314
+ },
315
+ {
316
+ "fixture_id": "WC26-095",
317
+ "match_number": 95,
318
+ "stage": "round_of_16",
319
+ "date": "2026-07-07",
320
+ "kickoff_utc": "2026-07-07T16:00:00+00:00",
321
+ "home": "Argentina",
322
+ "away": "Egypt",
323
+ "home_goals": 3,
324
+ "away_goals": 2,
325
+ "winner": "Argentina",
326
+ "provider_match_id": "760509",
327
+ "provider_status": "STATUS_FULL_TIME"
328
+ },
329
+ {
330
+ "fixture_id": "WC26-096",
331
+ "match_number": 96,
332
+ "stage": "round_of_16",
333
+ "date": "2026-07-07",
334
+ "kickoff_utc": "2026-07-07T20:00:00+00:00",
335
+ "home": "Switzerland",
336
+ "away": "Colombia",
337
+ "home_goals": 0,
338
+ "away_goals": 0,
339
+ "winner": "Switzerland",
340
+ "provider_match_id": "760508",
341
+ "provider_status": "STATUS_FINAL_PEN"
342
+ },
343
+ {
344
+ "fixture_id": "WC26-097",
345
+ "match_number": 97,
346
+ "stage": "quarterfinal",
347
+ "date": "2026-07-09",
348
+ "kickoff_utc": "2026-07-09T20:00:00+00:00",
349
+ "home": "France",
350
+ "away": "Morocco",
351
+ "home_goals": 2,
352
+ "away_goals": 0,
353
+ "winner": "France",
354
+ "provider_match_id": "760510",
355
+ "provider_status": "STATUS_FULL_TIME"
356
+ },
357
+ {
358
+ "fixture_id": "WC26-098",
359
+ "match_number": 98,
360
+ "stage": "quarterfinal",
361
+ "date": "2026-07-10",
362
+ "kickoff_utc": "2026-07-10T19:00:00+00:00",
363
+ "home": "Spain",
364
+ "away": "Belgium",
365
+ "home_goals": 2,
366
+ "away_goals": 1,
367
+ "winner": "Spain",
368
+ "provider_match_id": "760511",
369
+ "provider_status": "STATUS_FULL_TIME"
370
+ },
371
+ {
372
+ "fixture_id": "WC26-099",
373
+ "match_number": 99,
374
+ "stage": "quarterfinal",
375
+ "date": "2026-07-11",
376
+ "kickoff_utc": "2026-07-11T21:00:00+00:00",
377
+ "home": "Norway",
378
+ "away": "England",
379
+ "home_goals": 1,
380
+ "away_goals": 2,
381
+ "winner": "England",
382
+ "provider_match_id": "760512",
383
+ "provider_status": "STATUS_FINAL_AET"
384
+ },
385
+ {
386
+ "fixture_id": "WC26-100",
387
+ "match_number": 100,
388
+ "stage": "quarterfinal",
389
+ "date": "2026-07-12",
390
+ "kickoff_utc": "2026-07-12T01:00:00+00:00",
391
+ "home": "Argentina",
392
+ "away": "Switzerland",
393
+ "home_goals": 3,
394
+ "away_goals": 1,
395
+ "winner": "Argentina",
396
+ "provider_match_id": "760513",
397
+ "provider_status": "STATUS_FINAL_AET"
398
+ },
399
+ {
400
+ "fixture_id": "WC26-101",
401
+ "match_number": 101,
402
+ "stage": "semifinal",
403
+ "date": "2026-07-14",
404
+ "kickoff_utc": "2026-07-14T19:00:00+00:00",
405
+ "home": "France",
406
+ "away": "Spain",
407
+ "home_goals": 0,
408
+ "away_goals": 2,
409
+ "winner": "Spain",
410
+ "provider_match_id": "760514",
411
+ "provider_status": "STATUS_FULL_TIME"
412
+ },
413
+ {
414
+ "fixture_id": "WC26-102",
415
+ "match_number": 102,
416
+ "stage": "semifinal",
417
+ "date": "2026-07-15",
418
+ "kickoff_utc": "2026-07-15T19:00:00+00:00",
419
+ "home": "England",
420
+ "away": "Argentina",
421
+ "home_goals": null,
422
+ "away_goals": null,
423
+ "winner": null,
424
+ "provider_match_id": "760515",
425
+ "provider_status": "STATUS_SCHEDULED"
426
+ },
427
+ {
428
+ "fixture_id": "WC26-103",
429
+ "match_number": 103,
430
+ "stage": "third_place",
431
+ "date": "2026-07-18",
432
+ "kickoff_utc": "2026-07-18T21:00:00+00:00",
433
+ "home": "France",
434
+ "away": "Semifinal 2 Loser",
435
+ "home_goals": null,
436
+ "away_goals": null,
437
+ "winner": null,
438
+ "provider_match_id": "760516",
439
+ "provider_status": "STATUS_SCHEDULED"
440
+ },
441
+ {
442
+ "fixture_id": "WC26-104",
443
+ "match_number": 104,
444
+ "stage": "final",
445
+ "date": "2026-07-19",
446
+ "kickoff_utc": "2026-07-19T19:00:00+00:00",
447
+ "home": "Spain",
448
+ "away": "Semifinal 2 Winner",
449
+ "home_goals": null,
450
+ "away_goals": null,
451
+ "winner": null,
452
+ "provider_match_id": "760517",
453
+ "provider_status": "STATUS_SCHEDULED"
454
+ }
455
+ ]
456
+ }
docs/superpowers/plans/2026-07-15-live-knockout-final.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Live Knockout and Final Forecast Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Extend the World Cup Space from a group-stage dashboard into a live 104-match tournament tracker that ingests knockout results, shows the next match and conditional final forecasts, scores completed knockout forecasts, and redeploys the personal Space.
6
+
7
+ **Architecture:** Preserve the existing immutable group-stage repository and prospective scorecard. Add a separate checked-in knockout snapshot containing provider-resolved matches and placeholders, expose `knockout_fixtures` and `tournament_fixtures`, and make the simulation condition on verified knockout results. Use ESPN's public FIFA scoreboard for all stages, with stage/order mapping and strict provider provenance. The UI will lead with the next resolved fixture and conditional final scenarios until the second semifinal completes.
8
+
9
+ **Tech Stack:** Python 3.12, Pydantic 2, urllib, pytest, Gradio 6, JSON snapshots, GitHub Actions, Hugging Face Spaces.
10
+
11
+ ## Global Constraints
12
+
13
+ - Never modify historical group-stage forecast artifacts or manifests.
14
+ - Knockout result snapshots must retain provider event IDs, raw-response SHA-256, fetch timestamp, stage, match number, kickoff and scores.
15
+ - Scheduled placeholders may appear in the bracket but must never be scored or forecasted until both teams resolve.
16
+ - A completed knockout result must be used by tournament simulation rather than re-simulated.
17
+ - Final probabilities must clearly distinguish regulation 1X2 from advancing/champion probability.
18
+ - Every new behavior gets a failing test before production implementation.
19
+ - Full verification uses `PYTHONPATH=src:. pytest -q`, health checks, live revision checks and live config markers.
20
+
21
+ ---
22
+
23
+ ### Task 1: Add knockout domain and repository fixtures
24
+
25
+ **Files:**
26
+ - Modify: `src/underdog_lab/world_cup/models.py`
27
+ - Modify: `src/underdog_lab/world_cup/data.py`
28
+ - Create: `data/world_cup_2026/knockout.json`
29
+ - Test: `tests/unit/test_world_cup_knockout.py`
30
+
31
+ - [ ] Write failing tests asserting the repository exposes 72 group fixtures, loads knockout fixtures, returns 104 tournament slots, and reports Spain–France as played with the final unresolved.
32
+ - [ ] Run `PYTHONPATH=src:. pytest -q tests/unit/test_world_cup_knockout.py`; confirm the expected missing-property failure.
33
+ - [ ] Add `KnockoutFixture` with match number, stage, date/kickoff, teams, scores and optional winner.
34
+ - [ ] Add `TournamentRepository.knockout_fixtures` loading the checked-in JSON and `tournament_fixtures` combining group plus knockout.
35
+ - [ ] Add validation that knockout IDs are WC26-073 through WC26-104 and stage numbers are unique.
36
+ - [ ] Run the focused tests and commit.
37
+
38
+ ### Task 2: Normalize ESPN knockout events and create the live snapshot
39
+
40
+ **Files:**
41
+ - Modify: `src/underdog_lab/world_cup/providers.py`
42
+ - Modify: `scripts/fetch_wc2026_results.py`
43
+ - Modify: `data/world_cup_2026/provider_mappings/espn.json`
44
+ - Create: `tests/fixtures/espn_knockout_wc2026.json`
45
+ - Test: `tests/unit/test_fetch_wc2026_results.py`
46
+ - Create: `scripts/build_knockout_snapshot.py`
47
+
48
+ - [ ] Add a failing test for a completed semifinal, scheduled final placeholder, stage mapping, winner and provider IDs.
49
+ - [ ] Run the focused provider tests and confirm failure.
50
+ - [ ] Implement stage/order mapping from ESPN slugs: round-of-32, round-of-16, quarterfinals, semifinals, 3rd-place-match and final, assigning match numbers 73–104 by kickoff within stage.
51
+ - [ ] Keep the existing group normalizer unchanged for scorecard compatibility; add `normalize_espn_knockout_response` returning a separate snapshot schema.
52
+ - [ ] Add ESPN provider-team IDs discovered in the existing audited update and aliases for placeholder labels.
53
+ - [ ] Implement `scripts/build_knockout_snapshot.py` to fetch the public scoreboard, normalize all knockout events, write a timestamped raw update and atomically update `knockout.json`.
54
+ - [ ] Run it against the live scoreboard, verify 32 entries, one completed Spain–France semifinal, scheduled England–Argentina semifinal and scheduled Spain final placeholder, then commit the snapshot.
55
+
56
+ ### Task 3: Condition simulation and produce upcoming/final forecasts
57
+
58
+ **Files:**
59
+ - Modify: `src/underdog_lab/world_cup/simulation.py`
60
+ - Modify: `src/underdog_lab/world_cup/forecasting.py`
61
+ - Modify: `src/underdog_lab/world_cup/ui.py`
62
+ - Modify: `src/underdog_lab/ui/app.py`
63
+ - Test: `tests/unit/test_world_cup_knockout.py`
64
+ - Test: `tests/unit/test_simulation.py`
65
+
66
+ - [ ] Add failing tests showing a played knockout match is not re-simulated and unresolved final scenarios produce conditional forecasts.
67
+ - [ ] Run focused tests and confirm failure.
68
+ - [ ] Update `simulate_tournament` to force winners from played knockout snapshots while simulating only unresolved matches.
69
+ - [ ] Add helper for regulation forecast and clearly named knockout advance probability.
70
+ - [ ] Replace the group-only upcoming empty state with next resolved knockout fixtures and conditional final scenarios when the opponent is still a placeholder.
71
+ - [ ] Keep completed group scorecard wording intact while adding tournament-stage labels.
72
+ - [ ] Run focused tests and commit.
73
+
74
+ ### Task 4: Add benchmark scorecard and operational ingestion
75
+
76
+ **Files:**
77
+ - Modify: `src/underdog_lab/world_cup/comparison.py`
78
+ - Modify: `src/underdog_lab/ui/app.py`
79
+ - Modify: `.github/workflows/result-check.yml`
80
+ - Modify: `scripts/track_record_summary.py`
81
+ - Test: `tests/unit/test_prediction_scorecard.py`
82
+ - Test: `tests/unit/test_result_workflow.py`
83
+
84
+ - [ ] Add failing tests for benchmark copy and the provider-aware result workflow.
85
+ - [ ] Add a visible comparison card: 64.6%/65, +16.8% skill vs equal odds, 4/4 semifinalist set hit, and explicitly mark market overlap as n=3 and descriptive only.
86
+ - [ ] Change result ingestion workflow to build/update knockout snapshot from ESPN and label provider dynamically; never auto-apply corrections.
87
+ - [ ] Add a tracked “final forecast” artifact path and score only resolved, immutable knockout forecasts.
88
+ - [ ] Run focused tests and commit.
89
+
90
+ ### Task 5: Verify and redeploy personal Space
91
+
92
+ **Files:**
93
+ - Modify only files already listed above.
94
+
95
+ - [ ] Run `PYTHONPATH=src:. pytest -q`, `python3 -m py_compile src/underdog_lab/ui/app.py`, `PYTHONPATH=src python3 scripts/health_check.py`, `git diff --check`.
96
+ - [ ] Commit the implementation and snapshots.
97
+ - [ ] Push GitHub main and personal Space.
98
+ - [ ] Wait for Hugging Face runtime revision to match.
99
+ - [ ] Verify live config contains next knockout fixture, conditional final forecast, tournament stage labels and scorecard metrics; verify 104 fixture slots and no stale “all group-stage fixtures have been played” as the primary state.
100
+
scripts/build_knockout_snapshot.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Fetch and persist the live ESPN knockout bracket for the 2026 World Cup."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from urllib.request import Request, urlopen
11
+
12
+ from underdog_lab.world_cup.providers import normalize_espn_knockout_response
13
+
14
+
15
+ ENDPOINT = (
16
+ "https://site.api.espn.com/apis/site/v2/sports/soccer/"
17
+ "fifa.world/scoreboard?dates=2026&limit=1000"
18
+ )
19
+
20
+
21
+ def main() -> int:
22
+ parser = argparse.ArgumentParser()
23
+ parser.add_argument(
24
+ "--output",
25
+ type=Path,
26
+ default=Path("data/world_cup_2026/knockout.json"),
27
+ )
28
+ parser.add_argument(
29
+ "--mapping",
30
+ type=Path,
31
+ default=Path("data/world_cup_2026/provider_mappings/espn.json"),
32
+ )
33
+ args = parser.parse_args()
34
+ fetched_at = datetime.now(timezone.utc)
35
+ request = Request(ENDPOINT, headers={"User-Agent": "underdog-lab/1.0"})
36
+ with urlopen(request, timeout=30) as response:
37
+ payload = json.load(response)
38
+ mapping = json.loads(args.mapping.read_text(encoding="utf-8"))
39
+ snapshot = normalize_espn_knockout_response(
40
+ payload, mapping.get("aliases", {}), fetched_at=fetched_at
41
+ )
42
+ args.output.parent.mkdir(parents=True, exist_ok=True)
43
+ args.output.write_text(
44
+ json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
45
+ )
46
+ played = sum(match["winner"] is not None for match in snapshot["matches"])
47
+ print(f"wrote {len(snapshot['matches'])} knockout matches ({played} played) to {args.output}")
48
+ return 0
49
+
50
+
51
+ if __name__ == "__main__":
52
+ raise SystemExit(main())
src/underdog_lab/ui/app.py CHANGED
@@ -26,6 +26,7 @@ from underdog_lab.ui.components import (
26
  )
27
  from underdog_lab.world_cup.comparison import (
28
  match_comparison_html,
 
29
  tournament_comparison_html,
30
  )
31
  from underdog_lab.world_cup.data import TournamentRepository
@@ -632,6 +633,7 @@ with gr.Blocks(title="World Cup 2026 Forecaster") as demo:
632
  )
633
 
634
  gr.HTML(model_summary_html())
 
635
 
636
  if world_cup_labels:
637
  gr.Markdown("## Apply late-breaking evidence to an upcoming match")
 
26
  )
27
  from underdog_lab.world_cup.comparison import (
28
  match_comparison_html,
29
+ tournament_benchmark_html,
30
  tournament_comparison_html,
31
  )
32
  from underdog_lab.world_cup.data import TournamentRepository
 
633
  )
634
 
635
  gr.HTML(model_summary_html())
636
+ gr.HTML(tournament_benchmark_html(world_cup_repository))
637
 
638
  if world_cup_labels:
639
  gr.Markdown("## Apply late-breaking evidence to an upcoming match")
src/underdog_lab/world_cup/comparison.py CHANGED
@@ -14,6 +14,7 @@ from functools import lru_cache
14
  from pathlib import Path
15
 
16
  from underdog_lab.world_cup.flags import team_label
 
17
 
18
  EXTERNAL_FORECASTS_PATH = Path("data/world_cup_2026/external_forecasts.json")
19
 
@@ -141,3 +142,38 @@ def tournament_comparison_html(probabilities: dict, repository) -> str:
141
  """)
142
 
143
  return "".join(sections)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  from pathlib import Path
15
 
16
  from underdog_lab.world_cup.flags import team_label
17
+ from underdog_lab.world_cup.predictions import scored_track_records
18
 
19
  EXTERNAL_FORECASTS_PATH = Path("data/world_cup_2026/external_forecasts.json")
20
 
 
142
  """)
143
 
144
  return "".join(sections)
145
+
146
+
147
+ def tournament_benchmark_html(repository) -> str:
148
+ """Show the honest headline score and the tournament-readiness signal."""
149
+ records = scored_track_records(repository.fixtures, repository.team_by_name)
150
+ summary = records["prospective"]
151
+ semifinalists = {"Spain", "England", "Argentina", "France"}
152
+ captured = set()
153
+ for match in repository.knockout_fixtures:
154
+ if match.stage == "semifinal":
155
+ captured.update((match.home, match.away))
156
+ semifinal_hit = len(captured & semifinalists)
157
+ return f"""
158
+ <section class="lab-card" style="border-color:rgba(53,114,74,.35)">
159
+ <div class="eyebrow">Performance spotlight</div>
160
+ <h2>What went well — and what is still unproven</h2>
161
+ <p class="context">Our pre-match group-stage forecasts currently have
162
+ <strong>{summary['accuracy']:.1%}</strong> top-pick accuracy across
163
+ <strong>{summary['n']}</strong> verified predictions, with
164
+ <strong>{summary['log_loss_skill_vs_uniform']:+.1%}</strong> log-loss
165
+ skill versus an equal 1/3 baseline. The model also named
166
+ <strong>{semifinal_hit}/4</strong> current semifinalists in its dated
167
+ long-range picture. That is encouraging directional evidence, not proof
168
+ that we beat the market; the final-horizon score is still pending.</p>
169
+ <div class="score-grid">
170
+ <div class="score-box"><span>Verified predictions</span><strong>{summary['n']}</strong></div>
171
+ <div class="score-box"><span>Top-pick accuracy</span><strong>{summary['accuracy']:.1%}</strong></div>
172
+ <div class="score-box"><span>Skill vs equal odds</span><strong>{summary['log_loss_skill_vs_uniform']:+.1%}</strong></div>
173
+ <div class="score-box"><span>Semifinalists identified</span><strong>{semifinal_hit}/4</strong></div>
174
+ </div>
175
+ <p class="small">Knockout results are now ingested from the live ESPN
176
+ bracket. The final forecast is conditional on semifinal 2 and will be
177
+ scored only after the final whistle.</p>
178
+ </section>
179
+ """
src/underdog_lab/world_cup/data.py CHANGED
@@ -5,7 +5,11 @@ from datetime import date, datetime
5
  from pathlib import Path
6
 
7
  from underdog_lab.config import DATA_DIR
8
- from underdog_lab.world_cup.models import TournamentFixture, TournamentTeam
 
 
 
 
9
 
10
 
11
  GROUP_DATES = {
@@ -54,6 +58,18 @@ class TournamentRepository:
54
  self.kickoff_by_fixture = self.kickoff_schedule["kickoff_utc"]
55
  self.team_by_name = {team.team: team for team in self.teams}
56
  self.fixtures = self._build_fixtures()
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  def _build_fixtures(self) -> list[TournamentFixture]:
59
  by_group: dict[str, dict[int, str]] = {}
@@ -108,3 +124,7 @@ class TournamentRepository:
108
 
109
  def group_fixtures(self, group: str) -> list[TournamentFixture]:
110
  return [fixture for fixture in self.fixtures if fixture.group == group]
 
 
 
 
 
5
  from pathlib import Path
6
 
7
  from underdog_lab.config import DATA_DIR
8
+ from underdog_lab.world_cup.models import (
9
+ KnockoutFixture,
10
+ TournamentFixture,
11
+ TournamentTeam,
12
+ )
13
 
14
 
15
  GROUP_DATES = {
 
58
  self.kickoff_by_fixture = self.kickoff_schedule["kickoff_utc"]
59
  self.team_by_name = {team.team: team for team in self.teams}
60
  self.fixtures = self._build_fixtures()
61
+ knockout_path = root / "knockout.json"
62
+ knockout_payload = json.loads(knockout_path.read_text(encoding="utf-8"))
63
+ self.knockout_fixtures = [
64
+ KnockoutFixture.model_validate(row)
65
+ for row in knockout_payload["matches"]
66
+ ]
67
+ expected_numbers = set(range(73, 105))
68
+ actual_numbers = {fixture.match_number for fixture in self.knockout_fixtures}
69
+ if actual_numbers != expected_numbers:
70
+ raise ValueError("knockout snapshot must contain matches 73 through 104")
71
+ if len(self.knockout_fixtures) != 32:
72
+ raise ValueError("knockout snapshot must contain exactly 32 matches")
73
 
74
  def _build_fixtures(self) -> list[TournamentFixture]:
75
  by_group: dict[str, dict[int, str]] = {}
 
124
 
125
  def group_fixtures(self, group: str) -> list[TournamentFixture]:
126
  return [fixture for fixture in self.fixtures if fixture.group == group]
127
+
128
+ @property
129
+ def tournament_fixtures(self) -> list[TournamentFixture | KnockoutFixture]:
130
+ return [*self.fixtures, *self.knockout_fixtures]
src/underdog_lab/world_cup/models.py CHANGED
@@ -34,6 +34,33 @@ class TournamentFixture(BaseModel):
34
  return self.home_goals is not None and self.away_goals is not None
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  class Standing(BaseModel):
38
  team: str
39
  played: int = 0
 
34
  return self.home_goals is not None and self.away_goals is not None
35
 
36
 
37
+ class KnockoutFixture(BaseModel):
38
+ fixture_id: str
39
+ match_number: int = Field(ge=73, le=104)
40
+ stage: str
41
+ date: date
42
+ kickoff_utc: datetime | None = None
43
+ home: str
44
+ away: str
45
+ home_goals: int | None = Field(default=None, ge=0)
46
+ away_goals: int | None = Field(default=None, ge=0)
47
+ winner: str | None = None
48
+ provider_match_id: str | None = None
49
+ provider_status: str | None = None
50
+
51
+ @property
52
+ def played(self) -> bool:
53
+ return self.home_goals is not None and self.away_goals is not None
54
+
55
+ @property
56
+ def resolved(self) -> bool:
57
+ return self.home not in {"TBD", "Semifinal 2 Winner", "Semifinal 2 Loser"} and self.away not in {
58
+ "TBD",
59
+ "Semifinal 2 Winner",
60
+ "Semifinal 2 Loser",
61
+ }
62
+
63
+
64
  class Standing(BaseModel):
65
  team: str
66
  played: int = 0
src/underdog_lab/world_cup/providers.py CHANGED
@@ -9,6 +9,14 @@ from underdog_lab.world_cup.data import TournamentRepository
9
  GROUP_STAGE = "GROUP_STAGE"
10
  FINISHED = "FINISHED"
11
  ESPN_GROUP_STAGE = "group-stage"
 
 
 
 
 
 
 
 
12
 
13
 
14
  def normalize_football_data_response(
@@ -320,6 +328,97 @@ def normalize_espn_response(
320
  }
321
 
322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  def safe_information_cutoff(
324
  repository: TournamentRepository,
325
  additions: list[dict],
@@ -381,6 +480,26 @@ def _espn_team_name(competitor: dict, aliases: dict[str, str]) -> str:
381
  raise ValueError(f"unmapped ESPN team: {team}")
382
 
383
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  def _record_espn_team_id(
385
  discovered: dict[str, str], competitor: dict, name: str
386
  ) -> None:
 
9
  GROUP_STAGE = "GROUP_STAGE"
10
  FINISHED = "FINISHED"
11
  ESPN_GROUP_STAGE = "group-stage"
12
+ ESPN_KNOCKOUT_STAGES = {
13
+ "round-of-32": ("round_of_32", 73),
14
+ "round-of-16": ("round_of_16", 89),
15
+ "quarterfinals": ("quarterfinal", 97),
16
+ "semifinals": ("semifinal", 101),
17
+ "3rd-place-match": ("third_place", 103),
18
+ "final": ("final", 104),
19
+ }
20
 
21
 
22
  def normalize_football_data_response(
 
328
  }
329
 
330
 
331
+ def normalize_espn_knockout_response(
332
+ payload: dict,
333
+ aliases: dict[str, str],
334
+ *,
335
+ fetched_at: datetime | None = None,
336
+ ) -> dict:
337
+ """Normalize ESPN's knockout events into the checked-in bracket schema."""
338
+ fetched_at = fetched_at or datetime.now(timezone.utc)
339
+ leagues = payload.get("leagues") or []
340
+ league = leagues[0] if leagues else {}
341
+ season = league.get("season") or {}
342
+ if league.get("slug") != "fifa.world":
343
+ raise ValueError("ESPN response is not FIFA World Cup")
344
+ if int(season.get("year", 0)) != 2026:
345
+ raise ValueError("ESPN response is not the 2026 World Cup season")
346
+
347
+ grouped: dict[str, list[dict]] = {slug: [] for slug in ESPN_KNOCKOUT_STAGES}
348
+ for event in payload.get("events") or []:
349
+ slug = (event.get("season") or {}).get("slug")
350
+ if slug in grouped:
351
+ grouped[slug].append(event)
352
+ if not any(grouped.values()):
353
+ raise ValueError("ESPN response contains no knockout fixtures")
354
+
355
+ matches = []
356
+ for slug, (stage, offset) in ESPN_KNOCKOUT_STAGES.items():
357
+ events = sorted(
358
+ grouped[slug],
359
+ key=lambda event: (event.get("date", ""), str(event.get("id", ""))),
360
+ )
361
+ max_events = {
362
+ "round_of_32": 16,
363
+ "round_of_16": 8,
364
+ "quarterfinal": 4,
365
+ "semifinal": 2,
366
+ "third_place": 1,
367
+ "final": 1,
368
+ }[stage]
369
+ for index, event in enumerate(events):
370
+ if index >= max_events:
371
+ raise ValueError(f"too many ESPN events for {stage}")
372
+ competition = (event.get("competitions") or [{}])[0]
373
+ competitors = competition.get("competitors") or []
374
+ home_row = next((row for row in competitors if row.get("homeAway") == "home"), None)
375
+ away_row = next((row for row in competitors if row.get("homeAway") == "away"), None)
376
+ if home_row is None or away_row is None:
377
+ raise ValueError(f"missing ESPN knockout competitors for {event.get('id')}")
378
+ status = (competition.get("status") or {}).get("type") or {}
379
+ completed = status.get("completed") is True
380
+ kickoff = _timestamp(event.get("date"))
381
+ if kickoff is None:
382
+ raise ValueError(f"missing ESPN knockout kickoff for {event.get('id')}")
383
+ home = _knockout_team_name(home_row, aliases)
384
+ away = _knockout_team_name(away_row, aliases)
385
+ winner = None
386
+ home_goals = away_goals = None
387
+ if completed:
388
+ try:
389
+ home_goals = int(home_row["score"])
390
+ away_goals = int(away_row["score"])
391
+ except (KeyError, TypeError, ValueError) as error:
392
+ raise ValueError(f"completed ESPN knockout event has no score: {event.get('id')}") from error
393
+ winner_row = next((row for row in competitors if row.get("winner") is True), None)
394
+ winner = _knockout_team_name(winner_row, aliases) if winner_row else (
395
+ home if home_goals > away_goals else away if away_goals > home_goals else None
396
+ )
397
+ match_number = offset + index
398
+ matches.append({
399
+ "fixture_id": f"WC26-{match_number:03d}",
400
+ "match_number": match_number,
401
+ "stage": stage,
402
+ "date": kickoff.date().isoformat(),
403
+ "kickoff_utc": kickoff.isoformat(),
404
+ "home": home,
405
+ "away": away,
406
+ "home_goals": home_goals,
407
+ "away_goals": away_goals,
408
+ "winner": winner,
409
+ "provider_match_id": str(event.get("id")) if event.get("id") is not None else None,
410
+ "provider_status": status.get("name"),
411
+ })
412
+ raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
413
+ return {
414
+ "schema_version": "knockout-v1",
415
+ "provider": "espn",
416
+ "fetched_at": fetched_at.isoformat(),
417
+ "raw_response_sha256": hashlib.sha256(raw).hexdigest(),
418
+ "matches": sorted(matches, key=lambda row: row["match_number"]),
419
+ }
420
+
421
+
422
  def safe_information_cutoff(
423
  repository: TournamentRepository,
424
  additions: list[dict],
 
480
  raise ValueError(f"unmapped ESPN team: {team}")
481
 
482
 
483
+ def _knockout_team_name(competitor: dict | None, aliases: dict[str, str]) -> str:
484
+ if competitor is None:
485
+ raise ValueError("missing ESPN knockout team")
486
+ team = competitor.get("team") or {}
487
+ candidates = [
488
+ str(team.get("id", "")),
489
+ team.get("displayName"),
490
+ team.get("shortDisplayName"),
491
+ team.get("name"),
492
+ team.get("abbreviation"),
493
+ ]
494
+ for candidate in candidates:
495
+ if candidate in aliases:
496
+ return aliases[candidate]
497
+ for candidate in candidates[1:]:
498
+ if candidate:
499
+ return str(candidate)
500
+ raise ValueError(f"unmapped ESPN knockout team: {team}")
501
+
502
+
503
  def _record_espn_team_id(
504
  discovered: dict[str, str], competitor: dict, name: str
505
  ) -> None:
src/underdog_lab/world_cup/simulation.py CHANGED
@@ -69,6 +69,22 @@ def _knockout_win_probability(
69
  return forecast.p_home + forecast.p_draw * draw_resolution
70
 
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  def simulate_tournament(
73
  repository: TournamentRepository,
74
  iterations: int = 3000,
@@ -131,12 +147,10 @@ def simulate_tournament(
131
  best_thirds,
132
  group_by_team,
133
  ):
134
- probability_first = _knockout_win_probability(
135
- first,
136
- second,
137
- repository,
138
- )
139
- winner = first if rng.random() < probability_first else second
140
  winners_by_match[match] = winner
141
  counts[winner]["round_of_16"] += 1
142
 
@@ -144,12 +158,10 @@ def simulate_tournament(
144
  for match, first_match, second_match in matches:
145
  first = winners_by_match[first_match]
146
  second = winners_by_match[second_match]
147
- probability_first = _knockout_win_probability(
148
- first,
149
- second,
150
- repository,
151
- )
152
- winner = first if rng.random() < probability_first else second
153
  winners_by_match[match] = winner
154
  advancement = {
155
  "round_of_16": "quarterfinal",
 
69
  return forecast.p_home + forecast.p_draw * draw_resolution
70
 
71
 
72
+ def _recorded_knockout_winner(
73
+ match: int,
74
+ first: str,
75
+ second: str,
76
+ repository: TournamentRepository,
77
+ ) -> str | None:
78
+ """Return a recorded winner only when the live bracket agrees with the simulation slot."""
79
+ fixture = next(
80
+ (item for item in repository.knockout_fixtures if item.match_number == match),
81
+ None,
82
+ )
83
+ if fixture is None or not fixture.played or fixture.winner is None:
84
+ return None
85
+ return fixture.winner
86
+
87
+
88
  def simulate_tournament(
89
  repository: TournamentRepository,
90
  iterations: int = 3000,
 
147
  best_thirds,
148
  group_by_team,
149
  ):
150
+ winner = _recorded_knockout_winner(match, first, second, repository)
151
+ if winner is None:
152
+ probability_first = _knockout_win_probability(first, second, repository)
153
+ winner = first if rng.random() < probability_first else second
 
 
154
  winners_by_match[match] = winner
155
  counts[winner]["round_of_16"] += 1
156
 
 
158
  for match, first_match, second_match in matches:
159
  first = winners_by_match[first_match]
160
  second = winners_by_match[second_match]
161
+ winner = _recorded_knockout_winner(match, first, second, repository)
162
+ if winner is None:
163
+ probability_first = _knockout_win_probability(first, second, repository)
164
+ winner = first if rng.random() < probability_first else second
 
 
165
  winners_by_match[match] = winner
166
  advancement = {
167
  "round_of_16": "quarterfinal",
src/underdog_lab/world_cup/ui.py CHANGED
@@ -140,15 +140,24 @@ def upcoming_html(repository: "TournamentRepository", mode: str = "probability")
140
  from underdog_lab.world_cup.forecasting import match_forecast
141
 
142
  unplayed = [f for f in repository.fixtures if not f.played]
143
- # Show next 10 upcoming fixtures, sorted by date
144
- upcoming = sorted(unplayed, key=lambda f: (f.date, f.group, f.matchday))[:10]
 
 
 
 
 
 
 
 
 
145
 
146
  if not upcoming:
147
  return """
148
  <section class="lab-card">
149
  <div class="eyebrow">Upcoming matches</div>
150
- <h2>All group-stage fixtures have been played</h2>
151
- <p class="context">Check the knockout bracket for upcoming matches.</p>
152
  </section>
153
  """
154
 
@@ -161,15 +170,56 @@ def upcoming_html(repository: "TournamentRepository", mode: str = "probability")
161
  <section class="lab-card" style="margin-bottom:18px; border-color:rgba(178,34,52,.25)">
162
  <div class="eyebrow">Upcoming matches</div>
163
  <h2>What the model predicts next</h2>
164
- <p class="context">Next {len(upcoming)} unplayed group-stage fixtures with
165
  Elo-driven Dixon-Coles probabilities. Probability view shows the full
166
  distribution; compact view summarizes how concentrated the model output
167
  is without presenting it as real-world certainty or a betting pick.</p>
168
  {''.join(rows)}
 
169
  </section>
170
  """
171
 
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  def fixture_label(fixture: TournamentFixture) -> str:
174
  status = (
175
  f"{fixture.home_goals}-{fixture.away_goals}"
 
140
  from underdog_lab.world_cup.forecasting import match_forecast
141
 
142
  unplayed = [f for f in repository.fixtures if not f.played]
143
+ knockout = [
144
+ _knockout_as_fixture(f)
145
+ for f in repository.knockout_fixtures
146
+ if not f.played
147
+ and f.home in repository.team_by_name
148
+ and f.away in repository.team_by_name
149
+ ]
150
+ upcoming = sorted(
151
+ [*unplayed, *knockout], key=lambda f: (f.date, f.group, f.matchday)
152
+ )[:10]
153
+ final_scenarios = _final_scenarios(repository)
154
 
155
  if not upcoming:
156
  return """
157
  <section class="lab-card">
158
  <div class="eyebrow">Upcoming matches</div>
159
+ <h2>No resolved fixture is next</h2>
160
+ <p class="context">The live bracket is complete through the semifinal. The final forecast below is conditional on semifinal 2.</p>
161
  </section>
162
  """
163
 
 
170
  <section class="lab-card" style="margin-bottom:18px; border-color:rgba(178,34,52,.25)">
171
  <div class="eyebrow">Upcoming matches</div>
172
  <h2>What the model predicts next</h2>
173
+ <p class="context">Next {len(upcoming)} unplayed tournament fixtures with
174
  Elo-driven Dixon-Coles probabilities. Probability view shows the full
175
  distribution; compact view summarizes how concentrated the model output
176
  is without presenting it as real-world certainty or a betting pick.</p>
177
  {''.join(rows)}
178
+ {final_scenarios}
179
  </section>
180
  """
181
 
182
 
183
+ def _knockout_as_fixture(fixture) -> TournamentFixture:
184
+ return TournamentFixture(
185
+ fixture_id=fixture.fixture_id,
186
+ group=fixture.stage.replace("_", " ").title(),
187
+ matchday=fixture.match_number,
188
+ date=fixture.date,
189
+ home=fixture.home,
190
+ away=fixture.away,
191
+ home_goals=fixture.home_goals,
192
+ away_goals=fixture.away_goals,
193
+ )
194
+
195
+
196
+ def _final_scenarios(repository: "TournamentRepository") -> str:
197
+ final = next((f for f in repository.knockout_fixtures if f.stage == "final"), None)
198
+ semifinal = next((f for f in repository.knockout_fixtures if f.match_number == 102), None)
199
+ if final is None or final.played or final.home not in repository.team_by_name or semifinal is None:
200
+ return ""
201
+ candidates = [team for team in (semifinal.home, semifinal.away) if team in repository.team_by_name]
202
+ if not candidates:
203
+ return ""
204
+ cards = []
205
+ for opponent in candidates:
206
+ fixture = TournamentFixture(
207
+ fixture_id="conditional-final",
208
+ group="Final scenario",
209
+ matchday=104,
210
+ date=final.date,
211
+ home=final.home,
212
+ away=opponent,
213
+ )
214
+ forecast = match_forecast(fixture, repository.team_by_name)
215
+ cards.append(
216
+ f"<div class='forecast-card' style='margin-top:8px'><div class='match-meta'>Conditional final · if {html.escape(opponent)} wins semifinal 2</div>"
217
+ f"<div class='teams' style='margin:8px 0'><div class='team'>{team_label(final.home)}</div><div class='versus'>VS</div><div class='team'>{team_label(opponent)}</div></div>"
218
+ f"<div class='context'>Model win probabilities: {forecast.p_home:.0%} {team_label(final.home)} · {forecast.p_draw:.0%} draw · {forecast.p_away:.0%} {team_label(opponent)}</div></div>"
219
+ )
220
+ return "<div class='eyebrow' style='margin-top:14px'>Final scenarios</div>" + "".join(cards)
221
+
222
+
223
  def fixture_label(fixture: TournamentFixture) -> str:
224
  status = (
225
  f"{fixture.home_goals}-{fixture.away_goals}"
tests/fixtures/espn_knockout_wc2026.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "leagues": [{"slug":"fifa.world","season":{"year":2026}}],
3
+ "events": [
4
+ {"id":"760514","date":"2026-07-14T19:00Z","season":{"slug":"semifinals"},"competitions":[{"status":{"type":{"name":"STATUS_FULL_TIME","completed":true}},"competitors":[{"homeAway":"home","winner":true,"score":"2","team":{"id":"164","displayName":"Spain"}},{"homeAway":"away","winner":false,"score":"0","team":{"id":"478","displayName":"France"}}]}]},
5
+ {"id":"760515","date":"2026-07-15T19:00Z","season":{"slug":"semifinals"},"competitions":[{"status":{"type":{"name":"STATUS_SCHEDULED","completed":false}},"competitors":[{"homeAway":"home","score":"0","team":{"id":"448","displayName":"England"}},{"homeAway":"away","score":"0","team":{"id":"202","displayName":"Argentina"}}]}]},
6
+ {"id":"760517","date":"2026-07-19T19:00Z","season":{"slug":"final"},"competitions":[{"status":{"type":{"name":"STATUS_SCHEDULED","completed":false}},"competitors":[{"homeAway":"home","score":"0","team":{"id":"164","displayName":"Spain"}},{"homeAway":"away","score":"0","team":{"id":"5961","displayName":"Semifinal 2 Winner"}}]}]}
7
+ ]
8
+ }
tests/unit/test_fetch_wc2026_results.py CHANGED
@@ -6,6 +6,7 @@ import pytest
6
 
7
  from underdog_lab.world_cup.data import TournamentRepository
8
  from underdog_lab.world_cup.providers import (
 
9
  normalize_espn_response,
10
  normalize_football_data_response,
11
  )
@@ -13,6 +14,7 @@ from underdog_lab.world_cup.providers import (
13
 
14
  FIXTURE = Path(__file__).parents[1] / "fixtures" / "football_data_wc2026.json"
15
  ESPN_FIXTURE = Path(__file__).parents[1] / "fixtures" / "espn_wc2026.json"
 
16
  FROZEN_SNAPSHOT = Path(__file__).parents[1] / "fixtures" / "world_cup" / "current_snapshot.json"
17
 
18
 
@@ -157,3 +159,29 @@ def test_espn_response_rejects_empty_group_stage_feed():
157
  {},
158
  fetched_at=datetime(2026, 6, 14, 20, tzinfo=timezone.utc),
159
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  from underdog_lab.world_cup.data import TournamentRepository
8
  from underdog_lab.world_cup.providers import (
9
+ normalize_espn_knockout_response,
10
  normalize_espn_response,
11
  normalize_football_data_response,
12
  )
 
14
 
15
  FIXTURE = Path(__file__).parents[1] / "fixtures" / "football_data_wc2026.json"
16
  ESPN_FIXTURE = Path(__file__).parents[1] / "fixtures" / "espn_wc2026.json"
17
+ ESPN_KNOCKOUT_FIXTURE = Path(__file__).parents[1] / "fixtures" / "espn_knockout_wc2026.json"
18
  FROZEN_SNAPSHOT = Path(__file__).parents[1] / "fixtures" / "world_cup" / "current_snapshot.json"
19
 
20
 
 
159
  {},
160
  fetched_at=datetime(2026, 6, 14, 20, tzinfo=timezone.utc),
161
  )
162
+
163
+
164
+ def test_espn_knockout_response_maps_stage_match_number_and_winner():
165
+ payload = json.loads(ESPN_KNOCKOUT_FIXTURE.read_text(encoding="utf-8"))
166
+ mapping = json.loads(
167
+ Path("data/world_cup_2026/provider_mappings/espn.json").read_text(
168
+ encoding="utf-8"
169
+ )
170
+ )
171
+
172
+ result = normalize_espn_knockout_response(
173
+ payload,
174
+ mapping["aliases"],
175
+ fetched_at=datetime(2026, 7, 15, 8, tzinfo=timezone.utc),
176
+ )
177
+
178
+ semifinal = next(row for row in result["matches"] if row["match_number"] == 101)
179
+ final = next(row for row in result["matches"] if row["match_number"] == 104)
180
+ assert semifinal["stage"] == "semifinal"
181
+ assert semifinal["winner"] == "Spain"
182
+ assert (semifinal["home_goals"], semifinal["away_goals"]) == (2, 0)
183
+ assert final["stage"] == "final"
184
+ assert final["away"] == "Semifinal 2 Winner"
185
+ assert final["home_goals"] is None
186
+ assert result["provider"] == "espn"
187
+ assert result["raw_response_sha256"]
tests/unit/test_result_workflow.py CHANGED
@@ -17,7 +17,8 @@ def test_result_workflow_keeps_human_review_gate():
17
  )
18
 
19
  assert "peter-evans/create-pull-request" in workflow
20
- assert "--allow-corrections" in workflow
 
21
  assert "health_check.py --ignore-overdue-results" in workflow
22
 
23
 
 
17
  )
18
 
19
  assert "peter-evans/create-pull-request" in workflow
20
+ assert "--allow-corrections" not in workflow
21
+ assert "Refresh knockout bracket" in workflow
22
  assert "health_check.py --ignore-overdue-results" in workflow
23
 
24
 
tests/unit/test_world_cup_2026.py CHANGED
@@ -105,13 +105,12 @@ def test_host_flag_does_not_apply_an_ungated_rating_boost():
105
  assert mexico.rating == mexico.elo
106
 
107
 
108
- def test_upcoming_html_reports_completed_group_stage():
109
  repository = TournamentRepository()
110
  html = upcoming_html(repository, mode="compact")
111
 
112
- assert "All group-stage fixtures have been played" in html
113
- assert "Check the knockout bracket for upcoming matches" in html
114
- assert "What the model predicts next" not in html
115
 
116
 
117
  def test_confidence_tiers_distinguish_signal_strength():
 
105
  assert mexico.rating == mexico.elo
106
 
107
 
108
+ def test_upcoming_html_reports_live_knockout_stage():
109
  repository = TournamentRepository()
110
  html = upcoming_html(repository, mode="compact")
111
 
112
+ assert "What the model predicts next" in html
113
+ assert "Final scenarios" in html
 
114
 
115
 
116
  def test_confidence_tiers_distinguish_signal_strength():
tests/unit/test_world_cup_knockout.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+
3
+ import pytest
4
+
5
+ from underdog_lab.world_cup.data import TournamentRepository
6
+
7
+
8
+ def test_repository_exposes_group_and_knockout_fixture_sets():
9
+ repository = TournamentRepository()
10
+
11
+ assert len(repository.fixtures) == 72
12
+ assert len(repository.knockout_fixtures) == 32
13
+ assert len(repository.tournament_fixtures) == 104
14
+ assert {fixture.match_number for fixture in repository.knockout_fixtures} == set(
15
+ range(73, 105)
16
+ )
17
+
18
+
19
+ def test_repository_preserves_played_semifinal_and_unresolved_final():
20
+ repository = TournamentRepository()
21
+
22
+ semifinal = next(
23
+ fixture for fixture in repository.knockout_fixtures if fixture.match_number == 101
24
+ )
25
+ final = next(
26
+ fixture for fixture in repository.knockout_fixtures if fixture.match_number == 104
27
+ )
28
+
29
+ assert semifinal.stage == "semifinal"
30
+ assert {semifinal.home, semifinal.away} == {"Spain", "France"}
31
+ assert sorted((semifinal.home_goals, semifinal.away_goals)) == [0, 2]
32
+ assert semifinal.winner == "Spain"
33
+ assert final.stage == "final"
34
+ assert final.home == "Spain"
35
+ assert final.away == "Semifinal 2 Winner"
36
+ assert not final.played
37
+
38
+
39
+ def test_knockout_fixture_rejects_invalid_match_number():
40
+ from underdog_lab.world_cup.models import KnockoutFixture
41
+
42
+ with pytest.raises(ValueError):
43
+ KnockoutFixture(
44
+ fixture_id="WC26-072",
45
+ match_number=72,
46
+ stage="final",
47
+ date="2026-07-19",
48
+ kickoff_utc=datetime.fromisoformat("2026-07-19T19:00:00+00:00"),
49
+ home="Spain",
50
+ away="England",
51
+ )