feat(football): implement World Cup 2026 simulator and Empirical-Poisson Mixture Model
Build and Push Docker Image / build (push) Successful in 3m38s

This commit is contained in:
2026-06-19 16:15:13 -07:00
parent c21f572e1f
commit 8b9cd80d41
11 changed files with 898 additions and 13 deletions
+44
View File
@@ -0,0 +1,44 @@
package logic
// Team represents a football team.
type Team struct {
Name string `json:"name"`
Group string `json:"group"` // e.g., "A", "B", ..., "L"
}
// Match represents a group stage match between two teams.
type Match struct {
TeamA string `json:"team_a"`
TeamB string `json:"team_b"`
IsCompleted bool `json:"is_completed"`
ScoreA int `json:"score_a"`
ScoreB int `json:"score_b"`
}
// ScoreOutcome represents a possible match score and its probability.
type ScoreOutcome struct {
ScoreA int `json:"score_a"`
ScoreB int `json:"score_b"`
Probability float64 `json:"probability"`
}
// TeamStats holds the metrics used to rank teams within a group or across third-place teams.
type TeamStats struct {
TeamName string
Points int
GoalDifference int
GoalsScored int
// Fields used for tie-breaking comparison within the group
H2HPoints int
H2HGD int
H2HGS int
}
// TeamProbability represents the calculated qualification probabilities for a single team.
type TeamProbability struct {
TeamName string `json:"team_name"`
Group string `json:"group"`
DirectQualProb float64 `json:"direct_qual_prob"`
ThirdQualProb float64 `json:"third_qual_prob"`
TotalQualProb float64 `json:"total_qual_prob"`
}
+191
View File
@@ -0,0 +1,191 @@
package logic
import (
"math"
"math/rand"
"sync"
)
// RawScoreRecord represents the raw statistics for a scoreline in football history.
type RawScoreRecord struct {
GoalsA int `json:"goals_a"`
GoalsB int `json:"goals_b"`
Count int `json:"count"`
}
// WorldCupRawScores represents the actual historical frequencies of scorelines
// in the FIFA World Cup history (964 matches from 1930 to 2022).
var WorldCupRawScores = []RawScoreRecord{
{GoalsA: 1, GoalsB: 0, Count: 182},
{GoalsA: 2, GoalsB: 1, Count: 152},
{GoalsA: 2, GoalsB: 0, Count: 111},
{GoalsA: 1, GoalsB: 1, Count: 92},
{GoalsA: 0, GoalsB: 0, Count: 78},
{GoalsA: 3, GoalsB: 1, Count: 68},
{GoalsA: 3, GoalsB: 0, Count: 57},
{GoalsA: 3, GoalsB: 2, Count: 43},
{GoalsA: 2, GoalsB: 2, Count: 35},
{GoalsA: 4, GoalsB: 1, Count: 31},
{GoalsA: 4, GoalsB: 0, Count: 24},
{GoalsA: 4, GoalsB: 2, Count: 17},
{GoalsA: 6, GoalsB: 1, Count: 11},
{GoalsA: 5, GoalsB: 2, Count: 9},
{GoalsA: 5, GoalsB: 0, Count: 7},
{GoalsA: 3, GoalsB: 3, Count: 7},
{GoalsA: 5, GoalsB: 1, Count: 7},
{GoalsA: 6, GoalsB: 0, Count: 5},
{GoalsA: 7, GoalsB: 0, Count: 5},
{GoalsA: 4, GoalsB: 3, Count: 3},
{GoalsA: 7, GoalsB: 1, Count: 3},
{GoalsA: 8, GoalsB: 1, Count: 3},
{GoalsA: 4, GoalsB: 4, Count: 2},
{GoalsA: 6, GoalsB: 3, Count: 2},
{GoalsA: 9, GoalsB: 0, Count: 2},
{GoalsA: 5, GoalsB: 3, Count: 1},
{GoalsA: 6, GoalsB: 2, Count: 1},
{GoalsA: 7, GoalsB: 2, Count: 1},
{GoalsA: 7, GoalsB: 3, Count: 1},
{GoalsA: 6, GoalsB: 5, Count: 1},
{GoalsA: 8, GoalsB: 3, Count: 1},
{GoalsA: 10, GoalsB: 1, Count: 1},
{GoalsA: 7, GoalsB: 5, Count: 1},
}
// poissonProbability computes the Poisson probability P(k; lambda).
func poissonProbability(lambda float64, k int) float64 {
factorial := 1.0
for i := 1; i <= k; i++ {
factorial *= float64(i)
}
return (math.Pow(lambda, float64(k)) * math.Exp(-lambda)) / factorial
}
// DefaultScoreOutcomes returns the symmetric default probability distribution based on WorldCupRawScores.
func DefaultScoreOutcomes() []ScoreOutcome {
var totalGoals int
var totalMatches int
for _, r := range WorldCupRawScores {
totalGoals += (r.GoalsA + r.GoalsB) * r.Count
totalMatches += r.Count
}
if totalMatches == 0 {
return []ScoreOutcome{{ScoreA: 1, ScoreB: 1, Probability: 1.0}}
}
// 1. Calculate expected goals per team (lambda)
lambda := float64(totalGoals) / (2.0 * float64(totalMatches))
// 2. Compute Poisson 1D and 2D probabilities (up to MaxGoals = 10)
maxGoals := 10
poisson1D := make([]float64, maxGoals+1)
var sumP float64
for g := 0; g <= maxGoals; g++ {
p := poissonProbability(lambda, g)
poisson1D[g] = p
sumP += p
}
for g := 0; g <= maxGoals; g++ {
poisson1D[g] /= sumP
}
// 3. Build Empirical 2D map
type scoreKey struct {
goalsA int
goalsB int
}
empirical := make(map[scoreKey]float64)
for _, r := range WorldCupRawScores {
if r.GoalsA <= maxGoals && r.GoalsB <= maxGoals {
prob := float64(r.Count) / float64(totalMatches)
if r.GoalsA == r.GoalsB {
empirical[scoreKey{r.GoalsA, r.GoalsB}] = prob
} else {
empirical[scoreKey{r.GoalsA, r.GoalsB}] = prob / 2.0
empirical[scoreKey{r.GoalsB, r.GoalsA}] = prob / 2.0
}
}
}
// 4. Combine into Mixture Model (alpha = 0.99)
alpha := 0.99
var outcomes []ScoreOutcome
for ga := 0; ga <= maxGoals; ga++ {
for gb := 0; gb <= maxGoals; gb++ {
pPoisson := poisson1D[ga] * poisson1D[gb]
pEmpirical := empirical[scoreKey{ga, gb}]
pMix := alpha*pEmpirical + (1.0-alpha)*pPoisson
outcomes = append(outcomes, ScoreOutcome{
ScoreA: ga,
ScoreB: gb,
Probability: pMix,
})
}
}
// Normalize just in case of float64 precision drift
var sumMix float64
for _, o := range outcomes {
sumMix += o.Probability
}
for i := range outcomes {
outcomes[i].Probability /= sumMix
}
return outcomes
}
// ScoreModel holds the probability distribution used to sample random scores.
type ScoreModel struct {
Outcomes []ScoreOutcome
mu sync.RWMutex
}
// NewScoreModel creates a ScoreModel from custom score outcomes.
// If customOutcomes is empty, it uses DefaultScoreOutcomes().
func NewScoreModel(customOutcomes []ScoreOutcome) *ScoreModel {
sm := &ScoreModel{}
if len(customOutcomes) > 0 {
// Normalize custom outcomes to ensure they sum to 1.0
var sum float64
for _, o := range customOutcomes {
sum += o.Probability
}
if sum > 0 {
normalized := make([]ScoreOutcome, len(customOutcomes))
for i, o := range customOutcomes {
normalized[i] = ScoreOutcome{
ScoreA: o.ScoreA,
ScoreB: o.ScoreB,
Probability: o.Probability / sum,
}
}
sm.Outcomes = normalized
return sm
}
}
sm.Outcomes = DefaultScoreOutcomes()
return sm
}
// RandomScore samples a random score outcome based on the probability distribution.
func (sm *ScoreModel) RandomScore(r *rand.Rand) ScoreOutcome {
sm.mu.RLock()
outcomes := sm.Outcomes
sm.mu.RUnlock()
if len(outcomes) == 0 {
return ScoreOutcome{ScoreA: 0, ScoreB: 0, Probability: 1.0}
}
p := r.Float64()
var cumulative float64
for _, outcome := range outcomes {
cumulative += outcome.Probability
if p <= cumulative {
return outcome
}
}
return outcomes[len(outcomes)-1]
}
+125
View File
@@ -0,0 +1,125 @@
package logic
import (
"math/rand"
"sort"
"time"
)
// RunMonteCarlo runs a Monte Carlo simulation of the remaining matches.
// Returns a slice of TeamProbability sorted by Group and then by TotalQualProb descending.
func RunMonteCarlo(teams []Team, matches []Match, scoreOutcomes []ScoreOutcome, numSimulations int) []TeamProbability {
if numSimulations <= 0 {
numSimulations = 50000
}
r := rand.New(rand.NewSource(time.Now().UnixNano()))
sm := NewScoreModel(scoreOutcomes)
// Map teams to groups
teamGroups := make(map[string]string)
groupsTeams := make(map[string][]string)
for _, t := range teams {
teamGroups[t.Name] = t.Group
groupsTeams[t.Group] = append(groupsTeams[t.Group], t.Name)
}
directCounts := make(map[string]int)
thirdCounts := make(map[string]int)
// Separate completed and uncompleted matches
var completed []Match
var uncompleted []Match
for _, m := range matches {
if m.IsCompleted {
completed = append(completed, m)
} else {
uncompleted = append(uncompleted, m)
}
}
for sim := 0; sim < numSimulations; sim++ {
// Clone completed matches and simulate uncompleted ones
simMatches := make([]Match, len(completed)+len(uncompleted))
copy(simMatches, completed)
for i, m := range uncompleted {
outcome := sm.RandomScore(r)
simMatches[len(completed)+i] = Match{
TeamA: m.TeamA,
TeamB: m.TeamB,
IsCompleted: true,
ScoreA: outcome.ScoreA,
ScoreB: outcome.ScoreB,
}
}
// Calculate standings for each group
thirdPlaced := make([]*TeamStats, 0, 12)
for grp, grpTeams := range groupsTeams {
var grpMatches []Match
for _, m := range simMatches {
if teamGroups[m.TeamA] == grp && teamGroups[m.TeamB] == grp {
grpMatches = append(grpMatches, m)
}
}
// Shuffle group teams to randomize tie-breaks in SortGroupStandings
shuffledGrpTeams := make([]string, len(grpTeams))
copy(shuffledGrpTeams, grpTeams)
r.Shuffle(len(shuffledGrpTeams), func(i, j int) {
shuffledGrpTeams[i], shuffledGrpTeams[j] = shuffledGrpTeams[j], shuffledGrpTeams[i]
})
standings := CalculateStandings(shuffledGrpTeams, grpMatches)
// Top 2 qualify directly
if len(standings) > 0 {
directCounts[standings[0].TeamName]++
}
if len(standings) > 1 {
directCounts[standings[1].TeamName]++
}
// 3rd placed team goes to the pool
if len(standings) > 2 {
thirdPlaced = append(thirdPlaced, standings[2])
}
}
// Shuffle third-placed list to randomize cross-group tie-breaks in CompareThirdPlaced
r.Shuffle(len(thirdPlaced), func(i, j int) {
thirdPlaced[i], thirdPlaced[j] = thirdPlaced[j], thirdPlaced[i]
})
CompareThirdPlaced(thirdPlaced)
limit := 8
if len(thirdPlaced) < limit {
limit = len(thirdPlaced)
}
for i := 0; i < limit; i++ {
thirdCounts[thirdPlaced[i].TeamName]++
}
}
// Build result slice
res := make([]TeamProbability, 0, len(teams))
for _, t := range teams {
dQual := float64(directCounts[t.Name]) / float64(numSimulations)
tQual := float64(thirdCounts[t.Name]) / float64(numSimulations)
res = append(res, TeamProbability{
TeamName: t.Name,
Group: t.Group,
DirectQualProb: dQual,
ThirdQualProb: tQual,
TotalQualProb: dQual + tQual,
})
}
// Sort results by Group name first, then by TotalQualProb descending
sort.Slice(res, func(i, j int) bool {
if res[i].Group != res[j].Group {
return res[i].Group < res[j].Group
}
return res[i].TotalQualProb > res[j].TotalQualProb
})
return res
}
+54
View File
@@ -0,0 +1,54 @@
package logic
import (
"testing"
)
func TestCalculateStandings(t *testing.T) {
teams := []string{"MEX", "RSA", "KOR", "CZE"}
matches := []Match{
{TeamA: "MEX", TeamB: "RSA", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // MEX: 3 pts, RSA: 0 pts
{TeamA: "KOR", TeamB: "CZE", IsCompleted: true, ScoreA: 2, ScoreB: 1}, // KOR: 3 pts, CZE: 0 pts
}
standings := CalculateStandings(teams, matches)
if len(standings) != 4 {
t.Fatalf("expected 4 teams, got %d", len(standings))
}
// MEX and KOR should be top 2
if standings[0].TeamName != "MEX" && standings[0].TeamName != "KOR" {
t.Errorf("expected MEX or KOR as first, got %s", standings[0].TeamName)
}
}
func TestRunMonteCarlo(t *testing.T) {
teams := []Team{
{Name: "MEX", Group: "A"},
{Name: "RSA", Group: "A"},
{Name: "KOR", Group: "A"},
{Name: "CZE", Group: "A"},
}
matches := []Match{
{TeamA: "MEX", TeamB: "RSA", IsCompleted: true, ScoreA: 2, ScoreB: 0},
{TeamA: "KOR", TeamB: "CZE", IsCompleted: true, ScoreA: 2, ScoreB: 1},
{TeamA: "CZE", TeamB: "RSA", IsCompleted: false},
{TeamA: "MEX", TeamB: "KOR", IsCompleted: false},
{TeamA: "MEX", TeamB: "CZE", IsCompleted: false},
{TeamA: "KOR", TeamB: "RSA", IsCompleted: false},
}
// We only simulate Group A here (4 teams, 6 matches total).
// Note: 3rd place comparison won't succeed in putting any team to top 8 of third placed
// unless there are other groups, but it shouldn't crash.
probs := RunMonteCarlo(teams, matches, nil, 100)
if len(probs) != 4 {
t.Fatalf("expected probabilities for 4 teams, got %d", len(probs))
}
for _, p := range probs {
if p.TotalQualProb < 0 || p.TotalQualProb > 1.0 {
t.Errorf("invalid total qual probability for %s: %f", p.TeamName, p.TotalQualProb)
}
}
}
+161
View File
@@ -0,0 +1,161 @@
package logic
import (
"sort"
)
// CalculateStandings computes the stats for each team in a group given the match results.
func CalculateStandings(teams []string, matches []Match) []*TeamStats {
statsMap := make(map[string]*TeamStats)
for _, t := range teams {
statsMap[t] = &TeamStats{TeamName: t}
}
for _, m := range matches {
if !m.IsCompleted {
continue
}
sa, sb := statsMap[m.TeamA], statsMap[m.TeamB]
if sa == nil || sb == nil {
continue
}
sa.GoalsScored += m.ScoreA
sa.GoalDifference += m.ScoreA - m.ScoreB
sb.GoalsScored += m.ScoreB
sb.GoalDifference += m.ScoreB - m.ScoreA
if m.ScoreA > m.ScoreB {
sa.Points += 3
} else if m.ScoreA < m.ScoreB {
sb.Points += 3
} else {
sa.Points += 1
sb.Points += 1
}
}
res := make([]*TeamStats, 0, len(teams))
for _, s := range statsMap {
res = append(res, s)
}
// Sort the standings
SortGroupStandings(res, matches)
return res
}
// SortGroupStandings sorts the teams in a group based on the tie-breaking rules.
func SortGroupStandings(teams []*TeamStats, matches []Match) {
sort.Slice(teams, func(i, j int) bool {
ti, tj := teams[i], teams[j]
// 1. Points
if ti.Points != tj.Points {
return ti.Points > tj.Points
}
// 2. Goal Difference
if ti.GoalDifference != tj.GoalDifference {
return ti.GoalDifference > tj.GoalDifference
}
// 3. Goals Scored
if ti.GoalsScored != tj.GoalsScored {
return ti.GoalsScored > tj.GoalsScored
}
// 4-6. Head-to-Head (H2H)
// We dynamically compute H2H stats for all teams that are tied on Points, GD, and GS.
// Since we are sorting, we can check the relationship between ti and tj.
// First find all teams tied with ti and tj.
tiedTeams := []string{}
for _, t := range teams {
if t.Points == ti.Points && t.GoalDifference == ti.GoalDifference && t.GoalsScored == ti.GoalsScored {
tiedTeams = append(tiedTeams, t.TeamName)
}
}
if len(tiedTeams) > 1 {
h2hStats := computeH2H(tiedTeams, matches)
h2hi := h2hStats[ti.TeamName]
h2hj := h2hStats[tj.TeamName]
if h2hi != nil && h2hj != nil {
// H2H Points
if h2hi.Points != h2hj.Points {
return h2hi.Points > h2hj.Points
}
// H2H GD
if h2hi.GoalDifference != h2hj.GoalDifference {
return h2hi.GoalDifference > h2hj.GoalDifference
}
// H2H GS
if h2hi.GoalsScored != h2hj.GoalsScored {
return h2hi.GoalsScored > h2hj.GoalsScored
}
}
}
// 7. No fallback (retains the shuffled input order to randomize tie-breaks in Monte Carlo)
return false
})
}
// computeH2H calculates Points, GD, and GS only considering matches between the specified tied teams.
func computeH2H(tiedTeams []string, matches []Match) map[string]*TeamStats {
statsMap := make(map[string]*TeamStats)
isTied := make(map[string]bool)
for _, t := range tiedTeams {
statsMap[t] = &TeamStats{TeamName: t}
isTied[t] = true
}
for _, m := range matches {
if !m.IsCompleted {
continue
}
if isTied[m.TeamA] && isTied[m.TeamB] {
sa, sb := statsMap[m.TeamA], statsMap[m.TeamB]
sa.GoalsScored += m.ScoreA
sa.GoalDifference += m.ScoreA - m.ScoreB
sb.GoalsScored += m.ScoreB
sb.GoalDifference += m.ScoreB - m.ScoreA
if m.ScoreA > m.ScoreB {
sa.Points += 3
} else if m.ScoreA < m.ScoreB {
sb.Points += 3
} else {
sa.Points += 1
sb.Points += 1
}
}
}
return statsMap
}
// CompareThirdPlaced ranks the third-placed teams across groups.
func CompareThirdPlaced(thirdTeams []*TeamStats) {
sort.Slice(thirdTeams, func(i, j int) bool {
ti, tj := thirdTeams[i], thirdTeams[j]
// 1. Points
if ti.Points != tj.Points {
return ti.Points > tj.Points
}
// 2. Goal Difference
if ti.GoalDifference != tj.GoalDifference {
return ti.GoalDifference > tj.GoalDifference
}
// 3. Goals Scored
if ti.GoalsScored != tj.GoalsScored {
return ti.GoalsScored > tj.GoalsScored
}
// 4. No fallback (retains the shuffled input order to randomize tie-breaks in Monte Carlo)
return false
})
}