From 0813e362976338f6e48f14cf6867a528aab886a8 Mon Sep 17 00:00:00 2001 From: jedarden Date: Thu, 23 Apr 2026 01:22:19 -0400 Subject: [PATCH] fix(evolver): wire Nash mixture and meta weaknesses into LLM prompts, fix 4-D diversity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NashMixture and MetaWeaknesses fields to meta.Description and compute them from island population proportions (§10.2 PSRO) - Update behaviorDistance to support N-D vectors for 4-D MAP-Elites grid (aggression, economy, exploration, formation) - Wire NashMixture/MetaWeaknesses through FromMetaDescription converter so they actually reach the LLM prompt (was dead code before) - Align LLM prompt with plan §15.1/§15.5: correct combat rules (focus-fire), fog of war, HTTP protocol section, Nash mixture target - Fix diversity normalization from sqrt(2) (2-D) to 2.0 (4-D max) - Rename handleUIFeedback to handleCreateFeedback (§13.6 naming) - Update tests for new fields and corrected prompt text Co-Authored-By: Claude Opus 4.7 --- cmd/acb-api/server.go | 6 +- cmd/acb-evolver/internal/meta/builder.go | 133 +++++++++++++++++- cmd/acb-evolver/internal/meta/builder_test.go | 10 +- cmd/acb-evolver/internal/prompt/builder.go | 44 +++++- .../internal/prompt/builder_test.go | 2 +- cmd/acb-evolver/internal/prompt/convert.go | 2 + .../internal/prompt/convert_test.go | 8 ++ 7 files changed, 183 insertions(+), 22 deletions(-) diff --git a/cmd/acb-api/server.go b/cmd/acb-api/server.go index 972daf8..7256ab7 100644 --- a/cmd/acb-api/server.go +++ b/cmd/acb-api/server.go @@ -75,7 +75,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { voteMW := s.voteLtr.Middleware(ipKey, func() { metrics.RateLimitHits.WithLabelValues("vote").Inc() }) - mux.HandleFunc("POST /api/feedback", fbMW(http.HandlerFunc(s.handleUIFeedback)).ServeHTTP) + mux.HandleFunc("POST /api/feedback", fbMW(http.HandlerFunc(s.handleCreateFeedback)).ServeHTTP) mux.HandleFunc("GET /api/feedback/", s.handleGetFeedback) mux.HandleFunc("POST /api/feedback/", voteMW(http.HandlerFunc(s.handleFeedbackUpvote)).ServeHTTP) @@ -1492,10 +1492,10 @@ func (s *Server) handlePredictionHistory(w http.ResponseWriter, r *http.Request) }) } -// handleUIFeedback handles POST /api/feedback +// handleCreateFeedback handles POST /api/feedback // Accepts community replay feedback per plan §13.6. // Stores in replay_feedback table with type enum: insight, mistake, idea, highlight. -func (s *Server) handleUIFeedback(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return diff --git a/cmd/acb-evolver/internal/meta/builder.go b/cmd/acb-evolver/internal/meta/builder.go index 665f2a3..659fbe7 100644 --- a/cmd/acb-evolver/internal/meta/builder.go +++ b/cmd/acb-evolver/internal/meta/builder.go @@ -8,6 +8,7 @@ package meta import ( "context" + "fmt" "math" "sort" "strings" @@ -39,6 +40,11 @@ type Description struct { TopBots []BotInfo // DominantStrategy describes the current meta. DominantStrategy string + // NashMixture describes the Nash equilibrium mixture over the population + // (per plan §10.3 PSRO). Example: "40% swarm, 30% hunter, 30% gatherer". + NashMixture string + // MetaWeaknesses lists exploitable gaps in the current population. + MetaWeaknesses []string // IslandStats holds population metrics per island. IslandStats map[string]IslandStats } @@ -119,6 +125,12 @@ func (b *Builder) Build(ctx context.Context, topBotLimit int) (*Description, err // Infer dominant strategy from top performers desc.DominantStrategy = b.inferDominantStrategy(desc) + // Compute Nash mixture description from island population proportions + desc.NashMixture = b.computeNashMixture(desc) + + // Infer meta weaknesses from under-represented strategies + desc.MetaWeaknesses = b.inferMetaWeaknesses(desc) + return desc, nil } @@ -146,18 +158,27 @@ func calculateDiversity(programs []*evolverdb.Program) float64 { } avgDist := totalDist / float64(pairs) - // Normalize: max distance in 2D unit square is sqrt(2) ≈ 1.414 - return avgDist / 1.414 + // Normalize: max distance in 4D unit hypercube is sqrt(4) = 2.0 + return avgDist / 2.0 } // behaviorDistance computes Euclidean distance between behavior vectors. +// Supports 2-D and 4-D vectors (per plan §10.2 MAP-Elites: aggression, +// economy, exploration, formation). func behaviorDistance(a, b []float64) float64 { - if len(a) < 2 || len(b) < 2 { + n := len(a) + if n > len(b) { + n = len(b) + } + if n == 0 { return 0 } - dx := a[0] - b[0] - dy := a[1] - b[1] - return math.Sqrt(dx*dx + dy*dy) + var sum float64 + for i := 0; i < n; i++ { + d := a[i] - b[i] + sum += d * d + } + return math.Sqrt(sum) } // inferDominantStrategy analyzes the top bots and describes the meta. @@ -206,6 +227,106 @@ func (b *Builder) inferDominantStrategy(desc *Description) string { return strings.Join(strategies, " / ") } +// computeNashMixture produces a human-readable description of the Nash equilibrium +// mixture over the island population, expressed as proportional strategy weights. +// Per plan §10.3 PSRO: "the candidate should beat this mixture, not just one opponent." +func (b *Builder) computeNashMixture(desc *Description) string { + if len(desc.IslandStats) == 0 || desc.TotalBots == 0 { + return "" + } + + // Approximate Nash mixture from island population proportions. + // Each island represents a strategy archetype (§10.2). + strategyMap := map[string]string{ + evolverdb.IslandAlpha: "aggressive", + evolverdb.IslandBeta: "economy", + evolverdb.IslandGamma: "defensive", + evolverdb.IslandDelta: "mixed", + } + + type mixEntry struct { + name string + weight float64 + } + + var entries []mixEntry + totalPop := 0 + for _, island := range evolverdb.AllIslands { + stats, ok := desc.IslandStats[island] + if !ok || stats.Count == 0 { + continue + } + totalPop += stats.Count + entries = append(entries, mixEntry{ + name: strategyMap[island], + weight: float64(stats.Count), + }) + } + + if totalPop == 0 { + return "" + } + + // Normalize to percentages + var parts []string + for _, e := range entries { + pct := int(math.Round(e.weight / float64(totalPop) * 100)) + if pct > 0 { + parts = append(parts, fmt.Sprintf("%d%% %s", pct, e.name)) + } + } + + return strings.Join(parts, ", ") +} + +// inferMetaWeaknesses identifies exploitable gaps in the current population. +// Per plan §10.3: "Weaknesses in the current meta are highlighted." +func (b *Builder) inferMetaWeaknesses(desc *Description) []string { + var weaknesses []string + + // Check for empty islands — under-explored strategy spaces. + strategyMap := map[string]string{ + evolverdb.IslandAlpha: "aggression", + evolverdb.IslandBeta: "economy", + evolverdb.IslandGamma: "defense", + evolverdb.IslandDelta: "mixed/experimental", + } + + for _, island := range evolverdb.AllIslands { + stats, ok := desc.IslandStats[island] + if !ok || stats.Count == 0 { + weaknesses = append(weaknesses, + fmt.Sprintf("No bots exploring %s strategies — wide open niche", + strategyMap[island])) + } else if stats.Diversity < 0.2 { + weaknesses = append(weaknesses, + fmt.Sprintf("Low behavioral diversity in %s island (%.2f) — susceptible to counters", + strategyMap[island], stats.Diversity)) + } + } + + // Check for dominant strategy monopoly. + if desc.DominantStrategy != "" && len(desc.TopBots) >= 3 { + islandCounts := make(map[string]int) + for _, bot := range desc.TopBots { + islandCounts[bot.Island]++ + } + for island, count := range islandCounts { + if count >= len(desc.TopBots)*2/3 { + weaknesses = append(weaknesses, + fmt.Sprintf("Top bots are %s-heavy (%d/%d) — counter-strategies could exploit this", + strategyMap[island], count, len(desc.TopBots))) + } + } + } + + if len(weaknesses) == 0 { + weaknesses = append(weaknesses, "Meta is relatively balanced — no obvious exploit") + } + + return weaknesses +} + // BuildSimple creates a meta description without database access. // This is useful for testing or when database is not available. func BuildSimple(totalBots int, topBots []BotInfo, islandStats map[string]IslandStats) *Description { diff --git a/cmd/acb-evolver/internal/meta/builder_test.go b/cmd/acb-evolver/internal/meta/builder_test.go index e578a29..dfecc50 100644 --- a/cmd/acb-evolver/internal/meta/builder_test.go +++ b/cmd/acb-evolver/internal/meta/builder_test.go @@ -141,15 +141,15 @@ func TestCalculateDiversity_IdenticalPrograms(t *testing.T) { func TestCalculateDiversity_DiversePrograms(t *testing.T) { programs := []*evolverdb.Program{ - {ID: 1, BehaviorVector: []float64{0.0, 0.0}}, - {ID: 2, BehaviorVector: []float64{1.0, 1.0}}, + {ID: 1, BehaviorVector: []float64{0.0, 0.0, 0.0, 0.0}}, + {ID: 2, BehaviorVector: []float64{1.0, 1.0, 1.0, 1.0}}, } got := calculateDiversity(programs) - // Distance between (0,0) and (1,1) is sqrt(2), squared is 2 - // Normalized by 2 (max squared distance is 2) - // Expected: sqrt(2) / sqrt(2) = 1.0 + // Distance between (0,0,0,0) and (1,1,1,1) is sqrt(4) = 2.0 + // Normalized by 2.0 (max distance in 4D unit hypercube is sqrt(4) = 2.0) + // Expected: 2.0 / 2.0 = 1.0 if got < 0.9 || got > 1.1 { t.Errorf("expected diversity close to 1.0 for maximally diverse programs, got %f", got) } diff --git a/cmd/acb-evolver/internal/prompt/builder.go b/cmd/acb-evolver/internal/prompt/builder.go index e56def9..06d64c3 100644 --- a/cmd/acb-evolver/internal/prompt/builder.go +++ b/cmd/acb-evolver/internal/prompt/builder.go @@ -61,6 +61,13 @@ type MetaDescription struct { TopBots []BotSummary // DominantStrategy is a narrative description of the current meta. DominantStrategy string + // NashMixture describes the current Nash equilibrium mixture over the + // population (per plan §10.3 PSRO). When non-empty, the candidate should + // beat this mixture, not just one opponent. Example: + // "40% swarm, 30% hunter, 30% gatherer" + NashMixture string + // MetaWeaknesses lists known exploitable gaps in the current population. + MetaWeaknesses []string // IslandStats summarises each island's population and fitness. IslandStats map[string]IslandStat } @@ -135,12 +142,20 @@ func writeSystemContext(sb *strings.Builder, targetLang string) { sb.WriteString(" based on the parents and match analysis provided.\n\n") sb.WriteString("## Game Rules\n") - sb.WriteString("- Grid: 60×60 toroidal grid with walls, energy pickups, and player cores\n") + sb.WriteString("- Grid: toroidal (wraps horizontally and vertically) with walls, energy pickups, and player cores\n") sb.WriteString("- Bots spawn from your core (costs 3 energy). Spawn whenever energy ≥ 3.\n") sb.WriteString("- Each turn: move each bot one step (N/E/S/W) or stay. Submit all moves as JSON.\n") - sb.WriteString("- Collect energy tiles to gain energy. Attack enemy bots by moving onto them.\n") - sb.WriteString("- Win by: eliminating all enemy bots, controlling >50% score, or having the most score when turns run out (max 500).\n") - sb.WriteString("- Vision radius²=49 (~7 tiles). Attack by moving into an enemy.\n\n") + sb.WriteString("- Combat: focus-fire algorithm. A bot dies if ANY enemy within attack radius²=5 has ≤ its own enemy count.\n") + sb.WriteString(" - 2v1: the lone bot dies, pair survives. 1v1: both die. Tight formations are defensive.\n") + sb.WriteString("- Collect energy tiles (uncontested adjacent bots only) to gain energy.\n") + sb.WriteString("- Win by: sole survivor, dominance (≥80% bots for 100 turns), or highest score at turn 500.\n") + sb.WriteString("- Vision radius²=49 (~7 tiles). Fog of war: you only see tiles within vision of your bots.\n\n") + sb.WriteString("## HTTP Protocol\n") + sb.WriteString("- Your bot is an HTTP server listening on port 8080.\n") + sb.WriteString("- Engine POSTs game state (JSON) to /turn each turn. You have 3 seconds to respond.\n") + sb.WriteString("- Response: {\"moves\": [{\"row\":10,\"col\":15,\"direction\":\"N\"}], \"debug\": {...}}\n") + sb.WriteString("- Headers include HMAC-SHA256 signature: X-ACB-Signature, X-ACB-Match-Id, X-ACB-Turn.\n") + sb.WriteString("- 10 consecutive failures → bot marked crashed (units hold position for rest of match).\n\n") } func writeIslandContext(sb *strings.Builder, island string, generation int) { @@ -170,6 +185,11 @@ func writeMetaSection(sb *strings.Builder, meta MetaDescription) { if meta.DominantStrategy != "" { fmt.Fprintf(sb, "Dominant strategy: %s\n", meta.DominantStrategy) } + // Nash equilibrium mixture per plan §10.3 ("Beat this mix"). + if meta.NashMixture != "" { + fmt.Fprintf(sb, "Nash equilibrium mixture: %s\n", meta.NashMixture) + sb.WriteString("Your candidate must beat this mixture, not just one opponent.\n") + } if len(meta.TopBots) > 0 { sb.WriteString("\nTop-rated bots:\n") for i, bot := range meta.TopBots { @@ -184,6 +204,12 @@ func writeMetaSection(sb *strings.Builder, meta MetaDescription) { sb.WriteString(line) } } + if len(meta.MetaWeaknesses) > 0 { + sb.WriteString("\nKnown weaknesses in current population:\n") + for _, w := range meta.MetaWeaknesses { + fmt.Fprintf(sb, " - %s\n", w) + } + } if len(meta.IslandStats) > 0 { sb.WriteString("\nIsland population stats:\n") for _, island := range evolverdb.AllIslands { @@ -237,7 +263,10 @@ func writeParentSection(sb *strings.Builder, parents []*evolverdb.Program) { for i, p := range parents { fmt.Fprintf(sb, "### Parent %d (ID: %d, fitness: %.3f, language: %s)\n", i+1, p.ID, p.Fitness, p.Language) - if len(p.BehaviorVector) >= 2 { + if len(p.BehaviorVector) >= 4 { + fmt.Fprintf(sb, "Behavior: aggression=%.2f economy=%.2f exploration=%.2f formation=%.2f\n", + p.BehaviorVector[0], p.BehaviorVector[1], p.BehaviorVector[2], p.BehaviorVector[3]) + } else if len(p.BehaviorVector) >= 2 { fmt.Fprintf(sb, "Behavior: aggression=%.2f economy=%.2f\n", p.BehaviorVector[0], p.BehaviorVector[1]) } @@ -255,8 +284,9 @@ func writeTaskSection(sb *strings.Builder, targetLang string) { fmt.Fprintf(sb, "Write an **improved** bot strategy in **%s** that:\n", langDisplayName(targetLang)) sb.WriteString("1. Addresses the weaknesses and counter-strategies identified in the match analysis.\n") sb.WriteString("2. Builds on the best tactical patterns from the parent programs.\n") - sb.WriteString("3. Introduces at least one novel tactical improvement not present in the parents.\n") - sb.WriteString("4. Is complete and self-contained (define all required game types inline).\n\n") + sb.WriteString("3. Can beat the Nash mixture described above (not just one opponent).\n") + sb.WriteString("4. Is complete and self-contained (define all required game types inline).\n") + sb.WriteString("5. Fits in a single file under 10 KB.\n\n") sb.WriteString("Return **only** the complete bot code in a single fenced code block with no additional explanation:\n") sb.WriteString("```" + targetLang + "\n") sb.WriteString("// your complete bot code here\n") diff --git a/cmd/acb-evolver/internal/prompt/builder_test.go b/cmd/acb-evolver/internal/prompt/builder_test.go index 1fbe737..ca67660 100644 --- a/cmd/acb-evolver/internal/prompt/builder_test.go +++ b/cmd/acb-evolver/internal/prompt/builder_test.go @@ -14,7 +14,7 @@ func TestAssemble_containsGameRules(t *testing.T) { Generation: 1, } got := Assemble(r) - for _, want := range []string{"60×60", "energy", "spawn", "toroidal"} { + for _, want := range []string{"toroidal", "energy", "spawn", "focus-fire"} { if !strings.Contains(got, want) { t.Errorf("expected prompt to contain %q", want) } diff --git a/cmd/acb-evolver/internal/prompt/convert.go b/cmd/acb-evolver/internal/prompt/convert.go index 57d6022..be773c9 100644 --- a/cmd/acb-evolver/internal/prompt/convert.go +++ b/cmd/acb-evolver/internal/prompt/convert.go @@ -47,6 +47,8 @@ func FromMetaDescription(d *meta.Description) MetaDescription { return MetaDescription{ TotalBots: d.TotalBots, DominantStrategy: d.DominantStrategy, + NashMixture: d.NashMixture, + MetaWeaknesses: append([]string(nil), d.MetaWeaknesses...), TopBots: FromBotInfos(d.TopBots), IslandStats: FromIslandStatsMap(d.IslandStats), } diff --git a/cmd/acb-evolver/internal/prompt/convert_test.go b/cmd/acb-evolver/internal/prompt/convert_test.go index 427622d..536de83 100644 --- a/cmd/acb-evolver/internal/prompt/convert_test.go +++ b/cmd/acb-evolver/internal/prompt/convert_test.go @@ -120,6 +120,8 @@ func TestFromMetaDescription_Full(t *testing.T) { desc := &meta.Description{ TotalBots: 42, DominantStrategy: "aggressive", + NashMixture: "60% aggressive, 40% economy", + MetaWeaknesses: []string{"No bots exploring defense", "Low diversity in alpha"}, TopBots: []meta.BotInfo{ {Name: "bot1", Rating: 1600, Island: "alpha", Evolved: true}, {Name: "bot2", Rating: 1500, Island: "beta", Evolved: false}, @@ -137,6 +139,12 @@ func TestFromMetaDescription_Full(t *testing.T) { if got.DominantStrategy != "aggressive" { t.Errorf("expected DominantStrategy 'aggressive', got %q", got.DominantStrategy) } + if got.NashMixture != "60% aggressive, 40% economy" { + t.Errorf("expected NashMixture, got %q", got.NashMixture) + } + if len(got.MetaWeaknesses) != 2 || got.MetaWeaknesses[0] != "No bots exploring defense" { + t.Errorf("expected 2 MetaWeaknesses, got %v", got.MetaWeaknesses) + } if len(got.TopBots) != 2 { t.Errorf("expected 2 TopBots, got %d", len(got.TopBots)) }