- 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>
374 lines
9.9 KiB
Go
374 lines
9.9 KiB
Go
// Package meta builds meta-game descriptions for the evolution prompt.
|
|
//
|
|
// The meta builder aggregates data about the current competitive landscape:
|
|
// - Leaderboard: top-rated bots and their ratings
|
|
// - Dominant strategies: what tactics are currently winning
|
|
// - Island population stats: fitness and diversity per island
|
|
package meta
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
|
|
evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db"
|
|
)
|
|
|
|
// BotInfo represents a bot's competitive summary.
|
|
type BotInfo struct {
|
|
Name string
|
|
Rating float64
|
|
Island string
|
|
Evolved bool
|
|
}
|
|
|
|
// IslandStats captures population metrics for a single island.
|
|
type IslandStats struct {
|
|
Count int
|
|
AvgFitness float64
|
|
TopFitness float64
|
|
Diversity float64 // behavioral diversity (0-1)
|
|
}
|
|
|
|
// Description holds the complete meta-game snapshot.
|
|
type Description struct {
|
|
// TotalBots is the number of active bots on the ladder.
|
|
TotalBots int
|
|
// TopBots lists the highest-rated bots.
|
|
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
|
|
}
|
|
|
|
// Builder creates meta descriptions from database state.
|
|
type Builder struct {
|
|
store *evolverdb.Store
|
|
}
|
|
|
|
// NewBuilder creates a meta builder backed by the program store.
|
|
func NewBuilder(store *evolverdb.Store) *Builder {
|
|
return &Builder{store: store}
|
|
}
|
|
|
|
// Build constructs a meta description from current database state.
|
|
func (b *Builder) Build(ctx context.Context, topBotLimit int) (*Description, error) {
|
|
desc := &Description{
|
|
IslandStats: make(map[string]IslandStats),
|
|
}
|
|
|
|
// Gather island population stats
|
|
for _, island := range evolverdb.AllIslands {
|
|
programs, err := b.store.ListByIsland(ctx, island)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stats := IslandStats{
|
|
Count: len(programs),
|
|
}
|
|
|
|
if len(programs) > 0 {
|
|
// Calculate average and top fitness
|
|
var sum float64
|
|
for _, p := range programs {
|
|
sum += p.Fitness
|
|
}
|
|
stats.AvgFitness = sum / float64(len(programs))
|
|
stats.TopFitness = programs[0].Fitness // Already sorted by fitness DESC
|
|
|
|
// Calculate behavioral diversity using behavior vectors
|
|
stats.Diversity = calculateDiversity(programs)
|
|
}
|
|
|
|
desc.IslandStats[island] = stats
|
|
}
|
|
|
|
// Get promoted programs to represent the live ladder
|
|
promoted, err := b.store.ListPromoted(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
desc.TotalBots = len(promoted)
|
|
|
|
// Convert to BotInfo and sort by fitness (as proxy for rating)
|
|
topBots := make([]BotInfo, 0, len(promoted))
|
|
for _, p := range promoted {
|
|
topBots = append(topBots, BotInfo{
|
|
Name: p.BotName,
|
|
Rating: 1500 + p.Fitness*100, // Approximate rating from fitness
|
|
Island: p.Island,
|
|
Evolved: true,
|
|
})
|
|
}
|
|
|
|
// Sort by rating descending
|
|
sort.Slice(topBots, func(i, j int) bool {
|
|
return topBots[i].Rating > topBots[j].Rating
|
|
})
|
|
|
|
// Limit to top N bots
|
|
if len(topBots) > topBotLimit {
|
|
topBots = topBots[:topBotLimit]
|
|
}
|
|
desc.TopBots = topBots
|
|
|
|
// 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
|
|
}
|
|
|
|
// calculateDiversity computes behavioral diversity from behavior vectors.
|
|
// Returns a value between 0 (all identical) and 1 (maximally diverse).
|
|
func calculateDiversity(programs []*evolverdb.Program) float64 {
|
|
if len(programs) < 2 {
|
|
return 0
|
|
}
|
|
|
|
// Calculate average pairwise distance
|
|
var totalDist float64
|
|
var pairs int
|
|
|
|
for i := 0; i < len(programs); i++ {
|
|
for j := i + 1; j < len(programs); j++ {
|
|
dist := behaviorDistance(programs[i].BehaviorVector, programs[j].BehaviorVector)
|
|
totalDist += dist
|
|
pairs++
|
|
}
|
|
}
|
|
|
|
if pairs == 0 {
|
|
return 0
|
|
}
|
|
|
|
avgDist := totalDist / float64(pairs)
|
|
// 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 {
|
|
n := len(a)
|
|
if n > len(b) {
|
|
n = len(b)
|
|
}
|
|
if n == 0 {
|
|
return 0
|
|
}
|
|
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.
|
|
func (b *Builder) inferDominantStrategy(desc *Description) string {
|
|
if len(desc.TopBots) == 0 {
|
|
return "unknown (no promoted bots)"
|
|
}
|
|
|
|
// Count strategies by island
|
|
islandCounts := make(map[string]int)
|
|
for _, bot := range desc.TopBots {
|
|
islandCounts[bot.Island]++
|
|
}
|
|
|
|
// Find dominant island(s)
|
|
var dominantIslands []string
|
|
maxCount := 0
|
|
for island, count := range islandCounts {
|
|
if count > maxCount {
|
|
maxCount = count
|
|
dominantIslands = []string{island}
|
|
} else if count == maxCount {
|
|
dominantIslands = append(dominantIslands, island)
|
|
}
|
|
}
|
|
|
|
// Map islands to strategy descriptions
|
|
strategyMap := map[string]string{
|
|
evolverdb.IslandAlpha: "aggressive core-rushing",
|
|
evolverdb.IslandBeta: "energy-focused economy",
|
|
evolverdb.IslandGamma: "defensive adaptation",
|
|
evolverdb.IslandDelta: "experimental mixed",
|
|
}
|
|
|
|
var strategies []string
|
|
for _, island := range dominantIslands {
|
|
if s, ok := strategyMap[island]; ok {
|
|
strategies = append(strategies, s)
|
|
}
|
|
}
|
|
|
|
if len(strategies) == 0 {
|
|
return "diverse meta with no clear dominant strategy"
|
|
}
|
|
|
|
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 {
|
|
desc := &Description{
|
|
TotalBots: totalBots,
|
|
TopBots: topBots,
|
|
IslandStats: islandStats,
|
|
}
|
|
|
|
// Infer dominant strategy
|
|
if len(topBots) == 0 {
|
|
desc.DominantStrategy = "unknown (no promoted bots)"
|
|
return desc
|
|
}
|
|
|
|
islandCounts := make(map[string]int)
|
|
for _, bot := range topBots {
|
|
islandCounts[bot.Island]++
|
|
}
|
|
|
|
// Find most common island
|
|
maxCount := 0
|
|
dominantIsland := ""
|
|
for island, count := range islandCounts {
|
|
if count > maxCount {
|
|
maxCount = count
|
|
dominantIsland = island
|
|
}
|
|
}
|
|
|
|
strategyMap := map[string]string{
|
|
evolverdb.IslandAlpha: "aggressive core-rushing",
|
|
evolverdb.IslandBeta: "energy-focused economy",
|
|
evolverdb.IslandGamma: "defensive adaptation",
|
|
evolverdb.IslandDelta: "experimental mixed",
|
|
}
|
|
|
|
if s, ok := strategyMap[dominantIsland]; ok {
|
|
desc.DominantStrategy = s
|
|
} else {
|
|
desc.DominantStrategy = "diverse meta"
|
|
}
|
|
|
|
return desc
|
|
}
|