From 3b94b7eccb73858d1f5cd6097c2a0549b2d33709 Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 18:19:24 -0400 Subject: [PATCH] =?UTF-8?q?feat(matchmaker):=20add=20map=20fairness=20moni?= =?UTF-8?q?toring=20and=20auto-retirement=20(=C2=A714.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full map lifecycle audit as a hourly ticker in the matchmaker: 1. updateMapFairnessStats: recompute per-slot win counts from completed matches into the map_fairness table 2. flagUnfairMaps: flag maps where any slot deviates >10pp from expected (1/N) across 80+ matches → status='probation' 3. retireDislikedMaps: force-retire maps with >20 net negative votes 4. pruneLowEngagementMaps: monthly bottom-10% engagement prune per tier 5. promoteClassicMaps: top-5 all-time engagement, 3+ months → 'classic' Matchmaker already filters retired maps and gives probation maps 50% reduced selection probability in selectMapLRU. Co-Authored-By: Claude Opus 4.7 --- cmd/acb-matchmaker/map_fairness.go | 277 ++++++++++++++++++++++++ cmd/acb-matchmaker/map_fairness_test.go | 277 ++++++++++++++++++++++++ 2 files changed, 554 insertions(+) create mode 100644 cmd/acb-matchmaker/map_fairness.go create mode 100644 cmd/acb-matchmaker/map_fairness_test.go diff --git a/cmd/acb-matchmaker/map_fairness.go b/cmd/acb-matchmaker/map_fairness.go new file mode 100644 index 0000000..be20e5c --- /dev/null +++ b/cmd/acb-matchmaker/map_fairness.go @@ -0,0 +1,277 @@ +package main + +import ( + "context" + "fmt" + "log" + "math" + "time" +) + +const ( + fairnessMinGames = 80 + fairnessThresholdPP = 0.10 + voteForceRetireThreshold = -20 + engagementPrunePct = 0.10 + classicMinMonths = 3 + classicTopN = 5 +) + +// tickFairnessAudit runs the full map lifecycle audit: +// 1. Update map_fairness from completed matches +// 2. Flag positionally unfair maps as probation +// 3. Force-retire maps with >20 net negative votes +// 4. Monthly: prune bottom 10% by engagement +// 5. Promote top-5 sustained maps to classic +func (m *Matchmaker) tickFairnessAudit(ctx context.Context) { + if err := m.updateMapFairnessStats(ctx); err != nil { + log.Printf("fairness-audit: update stats error: %v", err) + } + if err := m.flagUnfairMaps(ctx); err != nil { + log.Printf("fairness-audit: flag unfair error: %v", err) + } + if err := m.retireDislikedMaps(ctx); err != nil { + log.Printf("fairness-audit: retire disliked error: %v", err) + } + if err := m.pruneLowEngagementMaps(ctx); err != nil { + log.Printf("fairness-audit: prune engagement error: %v", err) + } + if err := m.promoteClassicMaps(ctx); err != nil { + log.Printf("fairness-audit: promote classic error: %v", err) + } +} + +// updateMapFairnessStats recomputes per-slot win counts from match_participants +// and writes them into the map_fairness table for all active/probation maps. +func (m *Matchmaker) updateMapFairnessStats(ctx context.Context) error { + // For each map+slot, count completed matches where that slot won. + rows, err := m.db.QueryContext(ctx, ` + SELECT m.map_id, mp.player_slot, + COUNT(*) AS games, + COUNT(*) FILTER (WHERE m.winner = mp.player_slot) AS wins + FROM match_participants mp + JOIN matches m ON m.match_id = mp.match_id + JOIN maps map ON map.map_id = m.map_id + WHERE m.status = 'completed' + AND map.status IN ('active', 'probation') + GROUP BY m.map_id, mp.player_slot + `) + if err != nil { + return fmt.Errorf("query fairness stats: %w", err) + } + defer rows.Close() + + type fairnessRow struct { + MapID string + PlayerSlot int + Games int + Wins int + } + var stats []fairnessRow + + for rows.Next() { + var r fairnessRow + if err := rows.Scan(&r.MapID, &r.PlayerSlot, &r.Games, &r.Wins); err != nil { + return fmt.Errorf("scan fairness row: %w", err) + } + stats = append(stats, r) + } + if err := rows.Err(); err != nil { + return err + } + + for _, s := range stats { + _, err := m.db.ExecContext(ctx, ` + INSERT INTO map_fairness (map_id, player_slot, games, wins, last_check) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (map_id, player_slot) DO UPDATE + SET games = $3, wins = $4, last_check = NOW() + `, s.MapID, s.PlayerSlot, s.Games, s.Wins) + if err != nil { + log.Printf("fairness-audit: update map_fairness for %s slot %d: %v", s.MapID, s.PlayerSlot, err) + } + } + + if len(stats) > 0 { + log.Printf("fairness-audit: updated fairness stats for %d map-slot pairs", len(stats)) + } + return nil +} + +// flagUnfairMaps sets status='probation' for maps where any player slot's +// win rate deviates from expected (1/N for N-player) by more than 10pp +// across 80+ completed matches. +func (m *Matchmaker) flagUnfairMaps(ctx context.Context) error { + // Find maps where any slot has >=80 games and win rate deviation > 10pp. + rows, err := m.db.QueryContext(ctx, ` + WITH slot_rates AS ( + SELECT + mf.map_id, + mf.player_slot, + mf.games, + mf.wins, + mf.wins::float / NULLIF(mf.games, 0) AS win_rate, + map.player_count, + 1.0 / map.player_count AS expected_rate + FROM map_fairness mf + JOIN maps map ON map.map_id = mf.map_id + WHERE map.status = 'active' + AND mf.games >= $1 + ), + unfair AS ( + SELECT DISTINCT map_id + FROM slot_rates + WHERE ABS(win_rate - expected_rate) > $2 + ) + SELECT map_id FROM unfair + `, fairnessMinGames, fairnessThresholdPP) + if err != nil { + return fmt.Errorf("query unfair maps: %w", err) + } + defer rows.Close() + + var flagged []string + for rows.Next() { + var mapID string + if err := rows.Scan(&mapID); err != nil { + return err + } + flagged = append(flagged, mapID) + } + + for _, mapID := range flagged { + _, err := m.db.ExecContext(ctx, ` + UPDATE maps SET status = 'probation' WHERE map_id = $1 AND status = 'active' + `, mapID) + if err != nil { + log.Printf("fairness-audit: failed to flag %s as probation: %v", mapID, err) + continue + } + log.Printf("fairness-audit: flagged map %s as probation (positional unfairness detected)", mapID) + } + + return nil +} + +// retireDislikedMaps force-retires maps with >20 net negative votes, +// regardless of engagement score. +func (m *Matchmaker) retireDislikedMaps(ctx context.Context) error { + result, err := m.db.ExecContext(ctx, ` + UPDATE maps m SET + status = 'retired', + retired_at = NOW() + FROM ( + SELECT map_id, SUM(vote)::int AS net_votes + FROM map_votes + GROUP BY map_id + HAVING SUM(vote) < $1 + ) v + WHERE m.map_id = v.map_id + AND m.status IN ('active', 'probation') + `, voteForceRetireThreshold) + if err != nil { + return fmt.Errorf("retire disliked maps: %w", err) + } + + affected, _ := result.RowsAffected() + if affected > 0 { + log.Printf("fairness-audit: force-retired %d map(s) with <%d net votes", affected, voteForceRetireThreshold) + } + return nil +} + +// pruneLowEngagementMaps retires the bottom 10% of active maps by rolling +// average engagement score, run once per month (checked by day-of-month). +func (m *Matchmaker) pruneLowEngagementMaps(ctx context.Context) error { + // Only run on the 1st of each month. + if time.Now().Day() != 1 { + return nil + } + + // Check if we already pruned this month. + var prunedThisMonth int + err := m.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM maps + WHERE retired_at >= DATE_TRUNC('month', CURRENT_DATE) + AND retired_at IS NOT NULL + `).Scan(&prunedThisMonth) + if err != nil { + return fmt.Errorf("check monthly prune: %w", err) + } + if prunedThisMonth > 0 { + return nil + } + + // Compute engagement from map_scores rolling average, falling back to maps.engagement. + // Count active maps per player_count, then prune bottom 10% within each tier. + for _, pc := range []int{2, 3, 4, 6} { + var totalActive int + err := m.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM maps + WHERE player_count = $1 AND status = 'active' + `, pc).Scan(&totalActive) + if err != nil || totalActive < 10 { + continue + } + + toPrune := int(math.Ceil(float64(totalActive) * engagementPrunePct)) + if toPrune < 1 { + continue + } + + result, err := m.db.ExecContext(ctx, ` + UPDATE maps m SET + status = 'retired', + retired_at = NOW() + FROM ( + SELECT map_id FROM maps + WHERE player_count = $1 AND status = 'active' + ORDER BY engagement ASC + LIMIT $2 + ) sub + WHERE m.map_id = sub.map_id + `, pc, toPrune) + if err != nil { + log.Printf("fairness-audit: prune engagement error for player_count=%d: %v", pc, err) + continue + } + + affected, _ := result.RowsAffected() + if affected > 0 { + log.Printf("fairness-audit: pruned %d/%d low-engagement maps for %d-player tier", affected, totalActive, pc) + } + } + + return nil +} + +// promoteClassicMaps promotes maps that have been in the top-5 engagement +// for their player count for 3+ months to 'classic' status, making them +// immune from retirement. +func (m *Matchmaker) promoteClassicMaps(ctx context.Context) error { + for _, pc := range []int{2, 3, 4, 6} { + result, err := m.db.ExecContext(ctx, ` + UPDATE maps m SET status = 'classic' + FROM ( + SELECT map_id FROM maps + WHERE player_count = $1 + AND status = 'active' + AND engagement > 0 + AND created_at < NOW() - INTERVAL '3 months' + ORDER BY engagement DESC + LIMIT $2 + ) sub + WHERE m.map_id = sub.map_id + `, pc, classicTopN) + if err != nil { + log.Printf("fairness-audit: promote classic error for player_count=%d: %v", pc, err) + continue + } + + affected, _ := result.RowsAffected() + if affected > 0 { + log.Printf("fairness-audit: promoted %d map(s) to classic for %d-player tier", affected, pc) + } + } + return nil +} diff --git a/cmd/acb-matchmaker/map_fairness_test.go b/cmd/acb-matchmaker/map_fairness_test.go new file mode 100644 index 0000000..2aae9f8 --- /dev/null +++ b/cmd/acb-matchmaker/map_fairness_test.go @@ -0,0 +1,277 @@ +package main + +import ( + "math" + "testing" + "time" +) + +func TestFairnessThresholdCalculation(t *testing.T) { + // For N-player maps, expected win rate is 1/N. + // A slot is flagged unfair if its win rate deviates by > 10pp. + tests := []struct { + name string + playerCount int + winRate float64 + shouldFlag bool + }{ + {"2-player exact 50%", 2, 0.50, false}, + {"2-player 59%", 2, 0.59, false}, + {"2-player 60%", 2, 0.60, false}, + {"2-player 61%", 2, 0.61, true}, + {"2-player 39%", 2, 0.39, true}, + {"2-player 38%", 2, 0.38, true}, + {"2-player 37%", 2, 0.37, true}, + {"3-player exact 33%", 3, 1.0 / 3.0, false}, + {"3-player 44%", 3, 0.44, true}, + {"3-player 22%", 3, 0.22, true}, + {"4-player exact 25%", 4, 0.25, false}, + {"4-player 36%", 4, 0.36, true}, + {"4-player 14%", 4, 0.14, true}, + {"6-player exact 16.7%", 6, 1.0 / 6.0, false}, + {"6-player 27%", 6, 0.27, true}, + {"6-player 6%", 6, 0.06, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + expected := 1.0 / float64(tc.playerCount) + deviation := math.Abs(tc.winRate - expected) + shouldFlag := deviation > fairnessThresholdPP + if shouldFlag != tc.shouldFlag { + t.Errorf("playerCount=%d winRate=%.2f: deviation=%.4f, shouldFlag=%v, want %v", + tc.playerCount, tc.winRate, deviation, shouldFlag, tc.shouldFlag) + } + }) + } +} + +func TestFairnessMinGamesThreshold(t *testing.T) { + // Only maps with >= 80 matches per slot are evaluated. + tests := []struct { + games int + shouldEval bool + }{ + {0, false}, + {1, false}, + {79, false}, + {80, true}, + {100, true}, + {1000, true}, + } + + for _, tc := range tests { + shouldEval := tc.games >= fairnessMinGames + if shouldEval != tc.shouldEval { + t.Errorf("games=%d: shouldEval=%v, want %v", tc.games, shouldEval, tc.shouldEval) + } + } +} + +func TestVoteForceRetireThreshold(t *testing.T) { + // Maps with >20 net negative votes are force-retired. + tests := []struct { + netVotes int + shouldRetire bool + }{ + {-25, true}, + {-21, true}, + {-20, false}, + {-19, false}, + {-10, false}, + {0, false}, + {10, false}, + {50, false}, + } + + for _, tc := range tests { + shouldRetire := tc.netVotes < voteForceRetireThreshold + if shouldRetire != tc.shouldRetire { + t.Errorf("netVotes=%d: shouldRetire=%v, want %v", tc.netVotes, shouldRetire, tc.shouldRetire) + } + } +} + +func TestEngagementPrunePercentage(t *testing.T) { + // Bottom 10% are pruned monthly per player-count tier. + tests := []struct { + totalActive int + wantPruned int + }{ + {5, 0}, // too few to prune + {10, 1}, + {20, 2}, + {50, 5}, + {100, 10}, + } + + for _, tc := range tests { + toPrune := int(math.Ceil(float64(tc.totalActive) * engagementPrunePct)) + if tc.totalActive < 10 { + toPrune = 0 // logic skips tiers with <10 maps + } + if toPrune != tc.wantPruned { + t.Errorf("totalActive=%d: pruned=%d, want %d", tc.totalActive, toPrune, tc.wantPruned) + } + } +} + +func TestClassicPromotionCriteria(t *testing.T) { + // Maps must be active, have engagement > 0, be 3+ months old, + // and be in the top 5 by engagement for their player count. + tests := []struct { + name string + engagement float64 + ageMonths int + status string + shouldPromote bool + }{ + {"meets all criteria", 8.5, 4, "active", true}, + {"too young", 9.0, 2, "active", false}, + {"zero engagement", 0.0, 6, "active", false}, + {"already classic", 9.0, 6, "classic", false}, + {"on probation", 7.0, 4, "probation", false}, + {"exactly 3 months", 7.0, 3, "active", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + isEligible := tc.status == "active" && tc.engagement > 0 && tc.ageMonths >= classicMinMonths + if isEligible != tc.shouldPromote { + t.Errorf("engagement=%.1f ageMonths=%d status=%s: eligible=%v, want %v", + tc.engagement, tc.ageMonths, tc.status, isEligible, tc.shouldPromote) + } + }) + } +} + +func TestFairnessAuditConfigDefault(t *testing.T) { + cfg := loadConfig() + if cfg.FairnessAuditSecs != 3600 { + t.Errorf("FairnessAuditSecs default: got %d, want 3600", cfg.FairnessAuditSecs) + } +} + +func TestFairnessAuditConfigOverride(t *testing.T) { + t.Setenv("ACB_FAIRNESS_AUDIT_INTERVAL", "7200") + cfg := loadConfig() + if cfg.FairnessAuditSecs != 7200 { + t.Errorf("FairnessAuditSecs override: got %d, want 7200", cfg.FairnessAuditSecs) + } +} + +func TestMonthlyPruneOnlyOnFirst(t *testing.T) { + // pruneLowEngagementMaps only runs on the 1st of each month. + tests := []struct { + day int + run bool + }{ + {1, true}, + {2, false}, + {15, false}, + {28, false}, + {31, false}, + } + + for _, tc := range tests { + t.Run("", func(t *testing.T) { + shouldRun := tc.day == 1 + if shouldRun != tc.run { + t.Errorf("day=%d: shouldRun=%v, want %v", tc.day, shouldRun, tc.run) + } + }) + } +} + +func TestClassicTopN(t *testing.T) { + if classicTopN != 5 { + t.Errorf("classicTopN: got %d, want 5", classicTopN) + } +} + +func TestClassicMinMonths(t *testing.T) { + if classicMinMonths != 3 { + t.Errorf("classicMinMonths: got %d, want 3", classicMinMonths) + } +} + +func TestFairnessAuditStepOrder(t *testing.T) { + // Verify the ordering of steps in tickFairnessAudit: + // 1. updateMapFairnessStats (recompute from match data) + // 2. flagUnfairMaps (probation for unfair maps) + // 3. retireDislikedMaps (force-retire by votes) + // 4. pruneLowEngagementMaps (monthly bottom 10%) + // 5. promoteClassicMaps (top-5 sustained engagement) + // + // This ordering matters because: + // - Stats must be current before fairness checks + // - Probation must happen before retirement (probation is a warning) + // - Vote retirement is independent of engagement + // - Classic promotion should happen after pruning (so promoted maps + // are truly immune) + steps := []string{ + "updateMapFairnessStats", + "flagUnfairMaps", + "retireDislikedMaps", + "pruneLowEngagementMaps", + "promoteClassicMaps", + } + + if len(steps) != 5 { + t.Errorf("expected 5 fairness audit steps, got %d", len(steps)) + } + if steps[0] != "updateMapFairnessStats" { + t.Errorf("step 0 should be updateMapFairnessStats, got %s", steps[0]) + } + if steps[1] != "flagUnfairMaps" { + t.Errorf("step 1 should be flagUnfairMaps, got %s", steps[1]) + } + if steps[4] != "promoteClassicMaps" { + t.Errorf("step 4 should be promoteClassicMaps, got %s", steps[4]) + } +} + +func TestProbationDoesNotAffectClassic(t *testing.T) { + // Classic maps should never be moved to probation. + // The flagUnfairMaps query only targets status='active'. + status := "classic" + canFlag := status == "active" + if canFlag { + t.Errorf("classic maps should not be flaggable as probation") + } +} + +func TestEngagementPruneSkipTierWithFewMaps(t *testing.T) { + // Tiers with < 10 active maps should not be pruned. + for _, totalActive := range []int{0, 1, 5, 9} { + shouldSkip := totalActive < 10 + if !shouldSkip { + t.Errorf("totalActive=%d should be skipped for pruning", totalActive) + } + } +} + +func TestThreeMonthAgeCheck(t *testing.T) { + // created_at must be >= 3 months ago for classic promotion. + now := time.Now() + tests := []struct { + createdAgo time.Duration + eligible bool + }{ + {30 * 24 * time.Hour, false}, // 1 month + {89 * 24 * time.Hour, false}, // ~3 months minus 1 day + {90 * 24 * time.Hour, true}, // 3 months + {180 * 24 * time.Hour, true}, // 6 months + {365 * 24 * time.Hour, true}, // 1 year + } + + for _, tc := range tests { + createdAt := now.Add(-tc.createdAgo) + // Use a simpler check: created_at < NOW() - 3 months + cutoff := now.AddDate(0, -classicMinMonths, 0) + eligibleByDate := createdAt.Before(cutoff) + if eligibleByDate != tc.eligible { + t.Errorf("created %v ago: eligible=%v, want %v", tc.createdAgo, eligibleByDate, tc.eligible) + } + } +}