fix(evolver): wire Nash mixture and meta weaknesses into LLM prompts, fix 4-D diversity

- 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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-23 01:22:19 -04:00
parent 87f68044b4
commit 0813e36297
7 changed files with 183 additions and 22 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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)
}

View file

@ -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")

View file

@ -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)
}

View file

@ -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),
}

View file

@ -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))
}