diff --git a/cmd/acb-evolver/internal/llm/ensemble.go b/cmd/acb-evolver/internal/llm/ensemble.go new file mode 100644 index 0000000..1b0f706 --- /dev/null +++ b/cmd/acb-evolver/internal/llm/ensemble.go @@ -0,0 +1,286 @@ +// Package llm provides an OpenAI-compatible LLM client and utilities for +// extracting bot code from model responses. +package llm + +import ( + "context" + "sync" +) + +// EnsembleConfig configures the ensemble generation behavior. +type EnsembleConfig struct { + // NumCandidates is the number of candidates to generate in parallel. + // Default: 3 + NumCandidates int + // RefineTop indicates whether to refine the best candidate with the strong tier. + // Default: true + RefineTop bool + // FastTierMaxTokens is the max tokens for fast tier generation. + // Default: 4096 + FastTierMaxTokens int + // StrongTierMaxTokens is the max tokens for strong tier refinement. + // Default: 8192 + StrongTierMaxTokens int + // Temperature for generation. Default: 0.85 + Temperature float64 +} + +// DefaultEnsembleConfig returns a sensible default configuration. +func DefaultEnsembleConfig() EnsembleConfig { + return EnsembleConfig{ + NumCandidates: 3, + RefineTop: true, + FastTierMaxTokens: 4096, + StrongTierMaxTokens: 8192, + Temperature: 0.85, + } +} + +// EnsembleResult holds the results of ensemble generation. +type EnsembleResult struct { + // Best is the selected best candidate after optional refinement. + Best *Candidate + // BestRawText is the raw LLM output for the best candidate. + BestRawText string + // AllCandidates contains all generated candidates before selection. + AllCandidates []Candidate + // AllRawTexts contains all raw LLM outputs. + AllRawTexts []string + // RefinementApplied indicates if strong-tier refinement was applied. + RefinementApplied bool + // Errors contains any errors from individual generations. + Errors []error +} + +// Ensemble generates multiple candidates in parallel using the fast tier, +// selects the best one, and optionally refines it with the strong tier. +// +// The selection strategy prefers: +// 1. Longer code blocks (more complete implementations) +// 2. Code that passes basic structural checks +func (c *Client) Ensemble(ctx context.Context, prompt string, targetLang string, cfg EnsembleConfig) (*EnsembleResult, error) { + if cfg.NumCandidates <= 0 { + cfg.NumCandidates = 1 + } + + // Generate candidates in parallel + var wg sync.WaitGroup + var mu sync.Mutex + + candidates := make([]Candidate, 0, cfg.NumCandidates) + rawTexts := make([]string, 0, cfg.NumCandidates) + errors := make([]error, 0) + + for i := 0; i < cfg.NumCandidates; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + maxTokens := cfg.FastTierMaxTokens + if maxTokens == 0 { + maxTokens = defaultMaxTokens + } + temp := cfg.Temperature + if temp == 0 { + temp = defaultTemperature + } + + resp, err := c.Generate(ctx, GenerateRequest{ + Prompt: prompt, + Tier: TierFast, + MaxTokens: maxTokens, + Temperature: temp, + TargetLang: targetLang, + }) + + mu.Lock() + defer mu.Unlock() + + if err != nil { + errors = append(errors, err) + return + } + + if resp.Candidate != nil { + candidates = append(candidates, *resp.Candidate) + rawTexts = append(rawTexts, resp.RawText) + } + }(i) + } + wg.Wait() + + // If no candidates were generated, return error + if len(candidates) == 0 { + return &EnsembleResult{Errors: errors}, ErrNoValidCandidates + } + + // Select the best candidate (longest code block as heuristic) + bestIdx := selectBestCandidate(candidates) + best := &candidates[bestIdx] + bestRaw := rawTexts[bestIdx] + + result := &EnsembleResult{ + AllCandidates: candidates, + AllRawTexts: rawTexts, + Errors: errors, + } + + // Optionally refine with strong tier + if cfg.RefineTop { + refined, refineRaw, err := c.refineCandidate(ctx, prompt, best, targetLang, cfg) + if err == nil && refined != nil { + result.Best = refined + result.BestRawText = refineRaw + result.RefinementApplied = true + } else { + // Refinement failed, use the original best + result.Best = best + result.BestRawText = bestRaw + } + } else { + result.Best = best + result.BestRawText = bestRaw + } + + return result, nil +} + +// refineCandidate uses the strong tier to improve a candidate. +func (c *Client) refineCandidate(ctx context.Context, originalPrompt string, candidate *Candidate, targetLang string, cfg EnsembleConfig) (*Candidate, string, error) { + refinementPrompt := buildRefinementPrompt(originalPrompt, candidate) + + maxTokens := cfg.StrongTierMaxTokens + if maxTokens == 0 { + maxTokens = 8192 + } + + resp, err := c.Generate(ctx, GenerateRequest{ + Prompt: refinementPrompt, + Tier: TierStrong, + MaxTokens: maxTokens, + Temperature: 0.5, // Lower temperature for refinement + TargetLang: targetLang, + }) + if err != nil { + return nil, "", err + } + + return resp.Candidate, resp.RawText, nil +} + +// buildRefinementPrompt creates a prompt that asks the LLM to refine existing code. +func buildRefinementPrompt(originalPrompt string, candidate *Candidate) string { + return originalPrompt + ` + +--- + +## Previous Candidate (needs improvement) + +Here is a candidate implementation that needs refinement: + +` + "```" + candidate.Language + ` +` + candidate.Code + ` +` + "```" + ` + +Please improve this code by: +1. Fixing any bugs or edge cases +2. Improving tactical decision-making +3. Adding any missing functionality +4. Ensuring complete HTTP server implementation + +Return only the improved code in a fenced code block.` +} + +// selectBestCandidate picks the best candidate using heuristics. +// Currently uses code length as the primary metric. +func selectBestCandidate(candidates []Candidate) int { + if len(candidates) == 0 { + return -1 + } + + bestIdx := 0 + bestScore := scoreCandidate(candidates[0]) + + for i := 1; i < len(candidates); i++ { + score := scoreCandidate(candidates[i]) + if score > bestScore { + bestScore = score + bestIdx = i + } + } + + return bestIdx +} + +// scoreCandidate assigns a quality score to a candidate. +// Higher scores are better. +func scoreCandidate(c Candidate) float64 { + score := float64(len(c.Code)) + + // Bonus for having common code structures + switch c.Language { + case "go": + if containsAll(c.Code, "func main(", "http.HandleFunc", "ListenAndServe") { + score *= 1.5 + } + if contains(c.Code, "GetMoves") { + score *= 1.2 + } + case "python": + if containsAll(c.Code, "def ", "Flask", "app.run") || containsAll(c.Code, "def ", "HTTPServer") { + score *= 1.5 + } + case "rust": + if containsAll(c.Code, "fn main()", "HttpServer", "bind") { + score *= 1.5 + } + case "typescript", "javascript": + if containsAll(c.Code, "function", "createServer", "listen") { + score *= 1.5 + } + case "java": + if containsAll(c.Code, "public static void main", "HttpServer") { + score *= 1.5 + } + case "php": + if contains(c.Code, "$_POST") || contains(c.Code, "json_decode") { + score *= 1.3 + } + } + + return score +} + +// contains checks if s contains substr. +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr)) +} + +func containsSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// containsAll checks if s contains all substrings. +func containsAll(s string, substrs ...string) bool { + for _, substr := range substrs { + if !contains(s, substr) { + return false + } + } + return true +} + +// ErrNoValidCandidates is returned when ensemble generation produces no valid candidates. +var ErrNoValidCandidates = &NoValidCandidatesError{} + +// NoValidCandidatesError indicates that no valid code candidates were generated. +type NoValidCandidatesError struct{} + +func (e *NoValidCandidatesError) Error() string { + return "no valid code candidates were generated" +} diff --git a/cmd/acb-evolver/internal/llm/ensemble_test.go b/cmd/acb-evolver/internal/llm/ensemble_test.go new file mode 100644 index 0000000..4ad5c22 --- /dev/null +++ b/cmd/acb-evolver/internal/llm/ensemble_test.go @@ -0,0 +1,349 @@ +package llm + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDefaultEnsembleConfig(t *testing.T) { + cfg := DefaultEnsembleConfig() + if cfg.NumCandidates != 3 { + t.Errorf("expected NumCandidates=3, got %d", cfg.NumCandidates) + } + if !cfg.RefineTop { + t.Error("expected RefineTop=true") + } + if cfg.FastTierMaxTokens != 4096 { + t.Errorf("expected FastTierMaxTokens=4096, got %d", cfg.FastTierMaxTokens) + } + if cfg.StrongTierMaxTokens != 8192 { + t.Errorf("expected StrongTierMaxTokens=8192, got %d", cfg.StrongTierMaxTokens) + } + if cfg.Temperature != 0.85 { + t.Errorf("expected Temperature=0.85, got %f", cfg.Temperature) + } +} + +func TestSelectBestCandidate_Empty(t *testing.T) { + idx := selectBestCandidate([]Candidate{}) + if idx != -1 { + t.Errorf("expected -1 for empty candidates, got %d", idx) + } +} + +func TestSelectBestCandidate_Single(t *testing.T) { + candidates := []Candidate{ + {Code: "short", Language: "go"}, + } + idx := selectBestCandidate(candidates) + if idx != 0 { + t.Errorf("expected 0 for single candidate, got %d", idx) + } +} + +func TestSelectBestCandidate_PrefersLonger(t *testing.T) { + candidates := []Candidate{ + {Code: "short", Language: "go"}, + {Code: "this is a much longer piece of code that should score higher", Language: "go"}, + {Code: "medium length", Language: "go"}, + } + idx := selectBestCandidate(candidates) + if idx != 1 { + t.Errorf("expected 1 (longest), got %d", idx) + } +} + +func TestSelectBestCandidate_GoHttpBonus(t *testing.T) { + // Code with HTTP server patterns should score higher + shortWithHttp := `package main +import "net/http" +func main() { + http.HandleFunc("/", handler) + http.ListenAndServe(":8080", nil) +} +func handler(w http.ResponseWriter, r *http.Request) {}` + + longerNoHttp := strings.Repeat("x", 500) + + candidates := []Candidate{ + {Code: longerNoHttp, Language: "go"}, + {Code: shortWithHttp, Language: "go"}, + } + + idx := selectBestCandidate(candidates) + // The HTTP bonus should make the shorter but structured code win + if idx != 1 { + t.Errorf("expected 1 (HTTP structured), got %d", idx) + } +} + +func TestScoreCandidate_Bonuses(t *testing.T) { + tests := []struct { + name string + code string + lang string + minScore float64 + }{ + { + name: "go with HTTP", + code: "func main() { http.HandleFunc(); ListenAndServe() }", + lang: "go", + minScore: 100, // Should get bonus + }, + { + name: "python with Flask", + code: "def app(): Flask() app.run()", + lang: "python", + minScore: 100, + }, + { + name: "typescript with server", + code: "function createServer() listen()", + lang: "typescript", + minScore: 100, + }, + { + name: "rust with HTTP", + code: "fn main() { HttpServer::bind() }", + lang: "rust", + minScore: 100, + }, + { + name: "java with HTTP", + code: "public static void main HttpServer", + lang: "java", + minScore: 100, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + score := scoreCandidate(Candidate{Code: tc.code, Language: tc.lang}) + if score < tc.minScore { + t.Errorf("expected score >= %f for %s, got %f", tc.minScore, tc.name, score) + } + }) + } +} + +func TestContains(t *testing.T) { + if !contains("hello world", "world") { + t.Error("expected contains to find 'world'") + } + if contains("hello world", "mars") { + t.Error("expected contains to not find 'mars'") + } + if !contains("test", "test") { + t.Error("expected contains to find exact match") + } +} + +func TestContainsAll(t *testing.T) { + if !containsAll("hello world foo", "hello", "world") { + t.Error("expected containsAll to find both substrings") + } + if containsAll("hello world", "hello", "mars") { + t.Error("expected containsAll to fail on missing substring") + } +} + +func TestBuildRefinementPrompt(t *testing.T) { + original := "Write a bot" + candidate := &Candidate{ + Code: "func main() {}", + Language: "go", + } + + prompt := buildRefinementPrompt(original, candidate) + + if !strings.Contains(prompt, "Write a bot") { + t.Error("expected original prompt to be included") + } + if !strings.Contains(prompt, "Previous Candidate") { + t.Error("expected refinement section header") + } + if !strings.Contains(prompt, "func main() {}") { + t.Error("expected candidate code to be included") + } + if !strings.Contains(prompt, "```go") { + t.Error("expected go code block") + } +} + +func TestEnsembleResult_Empty(t *testing.T) { + result := &EnsembleResult{} + if result.Best != nil { + t.Error("expected nil Best for empty result") + } +} + +func TestNoValidCandidatesError(t *testing.T) { + err := ErrNoValidCandidates + if err.Error() != "no valid code candidates were generated" { + t.Errorf("unexpected error message: %s", err.Error()) + } +} + +// Integration test with mock server +func TestEnsemble_WithMockServer(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + // Return a valid response with code block + response := `{ + "choices": [{ + "message": { + "role": "assistant", + "content": "```go\npackage main\nfunc main() { /* code " + string(rune('A'+callCount)) + " */ }\n```" + } + }] + }` + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL, "") + + cfg := EnsembleConfig{ + NumCandidates: 2, + RefineTop: false, // Skip refinement for this test + FastTierMaxTokens: 1024, + Temperature: 0.7, + } + + result, err := client.Ensemble(context.Background(), "test prompt", "go", cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.AllCandidates) != 2 { + t.Errorf("expected 2 candidates, got %d", len(result.AllCandidates)) + } + + if result.Best == nil { + t.Fatal("expected non-nil Best candidate") + } + + if result.RefinementApplied { + t.Error("expected no refinement since RefineTop=false") + } +} + +func TestEnsemble_WithRefinement(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + var code string + if callCount <= 2 { + // Fast tier responses + code = "package main\nfunc main() { /* fast code */ }" + } else { + // Strong tier refinement + code = "package main\nfunc main() { /* refined code */ }" + } + response := `{ + "choices": [{ + "message": { + "role": "assistant", + "content": "```go\n` + code + `\n```" + } + }] + }` + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL, "") + + cfg := EnsembleConfig{ + NumCandidates: 2, + RefineTop: true, + FastTierMaxTokens: 1024, + StrongTierMaxTokens: 2048, + Temperature: 0.7, + } + + result, err := client.Ensemble(context.Background(), "test prompt", "go", cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !result.RefinementApplied { + t.Error("expected refinement to be applied") + } + + if result.Best == nil { + t.Fatal("expected non-nil Best candidate") + } + + // The refined code should contain "refined" + if !strings.Contains(result.Best.Code, "refined") { + t.Errorf("expected refined code, got: %s", result.Best.Code) + } +} + +func TestEnsemble_AllFail(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return invalid responses (no code blocks) + response := `{ + "choices": [{ + "message": { + "role": "assistant", + "content": "This is just text with no code blocks." + } + }] + }` + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL, "") + + cfg := EnsembleConfig{ + NumCandidates: 2, + RefineTop: false, + } + + result, err := client.Ensemble(context.Background(), "test prompt", "go", cfg) + if err != ErrNoValidCandidates { + t.Errorf("expected ErrNoValidCandidates, got: %v", err) + } + + if result == nil { + t.Fatal("expected non-nil result even on error") + } + + if len(result.Errors) == 0 { + t.Error("expected errors to be recorded") + } +} + +func TestEnsemble_ZeroCandidates(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{"choices": [{"message": {"content": "```go\nx\n```"}}]}` + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL, "") + + cfg := EnsembleConfig{ + NumCandidates: 0, // Should default to 1 + RefineTop: false, + } + + result, err := client.Ensemble(context.Background(), "test", "go", cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.AllCandidates) != 1 { + t.Errorf("expected 1 candidate (default), got %d", len(result.AllCandidates)) + } +} diff --git a/cmd/acb-evolver/internal/llm/extract_test.go b/cmd/acb-evolver/internal/llm/extract_test.go index 2fc2641..df67534 100644 --- a/cmd/acb-evolver/internal/llm/extract_test.go +++ b/cmd/acb-evolver/internal/llm/extract_test.go @@ -153,3 +153,138 @@ func TestLooksLikeCode(t *testing.T) { t.Error("expected plain prose not to look like code") } } + +func TestExtractCandidates_unlabeledBlock_proseSkipped(t *testing.T) { + // An unlabelled block that looks like prose should be skipped + text := "```\nThis is just prose, not code.\n```" + _, err := ExtractCandidates(text, "") + if err == nil { + t.Error("expected error for prose-only unlabeled block") + } +} + +func TestExtractCandidates_unlabeledBlock_codeKept(t *testing.T) { + // An unlabelled block that looks like code should be kept + text := "```\nfunc main() { return; }\n```" + candidates, err := ExtractCandidates(text, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 { + t.Errorf("expected 1 candidate, got %d", len(candidates)) + } +} + +func TestExtractCandidates_javaScriptAlias(t *testing.T) { + text := "```javascript\nconst x = 1;\n```" + candidates, err := ExtractCandidates(text, "typescript") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 || candidates[0].Language != "typescript" { + t.Errorf("expected javascript to map to typescript, got %+v", candidates) + } +} + +func TestExtractCandidates_jsAlias(t *testing.T) { + text := "```js\nconst x = 1;\n```" + candidates, err := ExtractCandidates(text, "typescript") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 || candidates[0].Language != "typescript" { + t.Errorf("expected js to map to typescript, got %+v", candidates) + } +} + +func TestExtractCandidates_rustAlias(t *testing.T) { + text := "```rs\nfn main() {}\n```" + candidates, err := ExtractCandidates(text, "rust") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 || candidates[0].Language != "rust" { + t.Errorf("expected rs to map to rust, got %+v", candidates) + } +} + +func TestExtractCandidates_whitespaceInLanguageTag(t *testing.T) { + // Language tag with trailing whitespace + text := "```go \npackage main\n```" + candidates, err := ExtractCandidates(text, "go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 { + t.Errorf("expected 1 candidate, got %d", len(candidates)) + } +} + +func TestExtractCandidates_noLanguageTag(t *testing.T) { + // Block with no language tag but code-like content + text := "```\nif (x > 0) { return x; }\n```" + candidates, err := ExtractCandidates(text, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 { + t.Errorf("expected 1 candidate, got %d", len(candidates)) + } +} + +func TestExtractBestCandidate_allSameLength(t *testing.T) { + // When all candidates are same length, first one wins + text := "```go\nabc\n```\n```go\nxyz\n```" + best, err := ExtractBestCandidate(text, "go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if best.Code != "abc" && best.Code != "xyz" { + t.Errorf("unexpected code: %q", best.Code) + } +} + +func TestExtractCandidates_codeWithBackticks(t *testing.T) { + // Code that contains backticks (nested) + text := "```go\nconst msg = `hello`\n```" + candidates, err := ExtractCandidates(text, "go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 { + t.Errorf("expected 1 candidate, got %d", len(candidates)) + } +} + +func TestValidLanguages(t *testing.T) { + validLangs := []string{"go", "python", "rust", "typescript", "java", "php"} + for _, lang := range validLangs { + if !ValidLanguages[lang] { + t.Errorf("expected %q to be a valid language", lang) + } + } +} + +func TestExtractCandidates_multipleBlocksSameLang(t *testing.T) { + // Multiple blocks of same language + text := "```go\npackage main\n```\nSome text\n```go\nfunc main() {}\n```" + candidates, err := ExtractCandidates(text, "go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 2 { + t.Errorf("expected 2 candidates, got %d", len(candidates)) + } +} + +func TestExtractCandidates_trailingNewlines(t *testing.T) { + // Code with trailing newlines + text := "```go\npackage main\n\n\n```" + candidates, err := ExtractCandidates(text, "go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 { + t.Errorf("expected 1 candidate, got %d", len(candidates)) + } +} diff --git a/cmd/acb-evolver/internal/meta/builder.go b/cmd/acb-evolver/internal/meta/builder.go new file mode 100644 index 0000000..baf7895 --- /dev/null +++ b/cmd/acb-evolver/internal/meta/builder.go @@ -0,0 +1,249 @@ +// 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" + "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 + // 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) + + 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 2D unit square is sqrt(2) ≈ 1.414 + return avgDist / 1.414 +} + +// behaviorDistance computes Euclidean distance between behavior vectors. +func behaviorDistance(a, b []float64) float64 { + if len(a) < 2 || len(b) < 2 { + return 0 + } + dx := a[0] - b[0] + dy := a[1] - b[1] + return dx*dx + dy*dy // Squared distance is sufficient for diversity +} + +// 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, " / ") +} + +// 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 { + 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 +} diff --git a/cmd/acb-evolver/internal/meta/builder_test.go b/cmd/acb-evolver/internal/meta/builder_test.go new file mode 100644 index 0000000..ee3788c --- /dev/null +++ b/cmd/acb-evolver/internal/meta/builder_test.go @@ -0,0 +1,232 @@ +package meta + +import ( + "testing" + + evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db" +) + +func TestBuildSimple_Basic(t *testing.T) { + topBots := []BotInfo{ + {Name: "TopBot1", Rating: 1600, Island: evolverdb.IslandAlpha, Evolved: true}, + {Name: "TopBot2", Rating: 1550, Island: evolverdb.IslandBeta, Evolved: true}, + {Name: "TopBot3", Rating: 1500, Island: evolverdb.IslandAlpha, Evolved: true}, + } + + islandStats := map[string]IslandStats{ + evolverdb.IslandAlpha: {Count: 10, AvgFitness: 0.5, TopFitness: 0.9, Diversity: 0.7}, + evolverdb.IslandBeta: {Count: 8, AvgFitness: 0.4, TopFitness: 0.8, Diversity: 0.6}, + } + + got := BuildSimple(20, topBots, islandStats) + + if got.TotalBots != 20 { + t.Errorf("expected TotalBots 20, got %d", got.TotalBots) + } + + if len(got.TopBots) != 3 { + t.Errorf("expected 3 TopBots, got %d", len(got.TopBots)) + } + + if got.TopBots[0].Name != "TopBot1" { + t.Errorf("expected TopBot1 as first, got %s", got.TopBots[0].Name) + } + + if got.DominantStrategy == "" { + t.Error("expected non-empty DominantStrategy") + } + + if len(got.IslandStats) != 2 { + t.Errorf("expected 2 IslandStats, got %d", len(got.IslandStats)) + } +} + +func TestBuildSimple_EmptyBots(t *testing.T) { + got := BuildSimple(0, nil, nil) + + if got.TotalBots != 0 { + t.Errorf("expected TotalBots 0, got %d", got.TotalBots) + } + + if len(got.TopBots) != 0 { + t.Errorf("expected 0 TopBots, got %d", len(got.TopBots)) + } + + if got.DominantStrategy != "unknown (no promoted bots)" { + t.Errorf("expected unknown meta message, got %q", got.DominantStrategy) + } +} + +func TestBuildSimple_DominantStrategy_Alpha(t *testing.T) { + // Alpha (aggressive) has most top bots + topBots := []BotInfo{ + {Name: "A1", Rating: 1600, Island: evolverdb.IslandAlpha, Evolved: true}, + {Name: "A2", Rating: 1550, Island: evolverdb.IslandAlpha, Evolved: true}, + {Name: "B1", Rating: 1500, Island: evolverdb.IslandBeta, Evolved: true}, + } + + got := BuildSimple(10, topBots, nil) + + if got.DominantStrategy != "aggressive core-rushing" { + t.Errorf("expected 'aggressive core-rushing', got %q", got.DominantStrategy) + } +} + +func TestBuildSimple_DominantStrategy_Beta(t *testing.T) { + // Beta (economic) has most top bots + topBots := []BotInfo{ + {Name: "B1", Rating: 1600, Island: evolverdb.IslandBeta, Evolved: true}, + {Name: "B2", Rating: 1550, Island: evolverdb.IslandBeta, Evolved: true}, + {Name: "A1", Rating: 1500, Island: evolverdb.IslandAlpha, Evolved: true}, + } + + got := BuildSimple(10, topBots, nil) + + if got.DominantStrategy != "energy-focused economy" { + t.Errorf("expected 'energy-focused economy', got %q", got.DominantStrategy) + } +} + +func TestBuildSimple_DominantStrategy_Gamma(t *testing.T) { + topBots := []BotInfo{ + {Name: "G1", Rating: 1600, Island: evolverdb.IslandGamma, Evolved: true}, + {Name: "G2", Rating: 1550, Island: evolverdb.IslandGamma, Evolved: true}, + } + + got := BuildSimple(10, topBots, nil) + + if got.DominantStrategy != "defensive adaptation" { + t.Errorf("expected 'defensive adaptation', got %q", got.DominantStrategy) + } +} + +func TestBuildSimple_DominantStrategy_Delta(t *testing.T) { + topBots := []BotInfo{ + {Name: "D1", Rating: 1600, Island: evolverdb.IslandDelta, Evolved: true}, + {Name: "D2", Rating: 1550, Island: evolverdb.IslandDelta, Evolved: true}, + } + + got := BuildSimple(10, topBots, nil) + + if got.DominantStrategy != "experimental mixed" { + t.Errorf("expected 'experimental mixed', got %q", got.DominantStrategy) + } +} + +func TestCalculateDiversity_SingleProgram(t *testing.T) { + programs := []*evolverdb.Program{ + {ID: 1, BehaviorVector: []float64{0.5, 0.5}}, + } + + got := calculateDiversity(programs) + + if got != 0 { + t.Errorf("expected diversity 0 for single program, got %f", got) + } +} + +func TestCalculateDiversity_IdenticalPrograms(t *testing.T) { + programs := []*evolverdb.Program{ + {ID: 1, BehaviorVector: []float64{0.5, 0.5}}, + {ID: 2, BehaviorVector: []float64{0.5, 0.5}}, + {ID: 3, BehaviorVector: []float64{0.5, 0.5}}, + } + + got := calculateDiversity(programs) + + if got != 0 { + t.Errorf("expected diversity 0 for identical programs, got %f", got) + } +} + +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}}, + } + + 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 + if got < 0.9 || got > 1.1 { + t.Errorf("expected diversity close to 1.0 for maximally diverse programs, got %f", got) + } +} + +func TestCalculateDiversity_EmptyPrograms(t *testing.T) { + got := calculateDiversity(nil) + + if got != 0 { + t.Errorf("expected diversity 0 for nil programs, got %f", got) + } +} + +func TestCalculateDiversity_NoBehaviorVector(t *testing.T) { + programs := []*evolverdb.Program{ + {ID: 1, BehaviorVector: nil}, + {ID: 2, BehaviorVector: []float64{}}, + } + + got := calculateDiversity(programs) + + if got != 0 { + t.Errorf("expected diversity 0 for programs without behavior vectors, got %f", got) + } +} + +func TestBehaviorDistance(t *testing.T) { + tests := []struct { + name string + a, b []float64 + expected float64 + }{ + {"same point", []float64{0.5, 0.5}, []float64{0.5, 0.5}, 0}, + {"unit apart x", []float64{0.0, 0.0}, []float64{1.0, 0.0}, 1}, + {"unit apart y", []float64{0.0, 0.0}, []float64{0.0, 1.0}, 1}, + {"diagonal", []float64{0.0, 0.0}, []float64{1.0, 1.0}, 2}, + {"nil vector a", nil, []float64{0.5, 0.5}, 0}, + {"nil vector b", []float64{0.5, 0.5}, nil, 0}, + {"short vector a", []float64{0.5}, []float64{0.5, 0.5}, 0}, + {"short vector b", []float64{0.5, 0.5}, []float64{0.5}, 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := behaviorDistance(tc.a, tc.b) + if got != tc.expected { + t.Errorf("expected distance %f, got %f", tc.expected, got) + } + }) + } +} + +func TestIslandStats_Values(t *testing.T) { + islandStats := map[string]IslandStats{ + evolverdb.IslandAlpha: {Count: 5, AvgFitness: 0.75, TopFitness: 0.95, Diversity: 0.8}, + } + + got := BuildSimple(5, nil, islandStats) + + alphaStats, ok := got.IslandStats[evolverdb.IslandAlpha] + if !ok { + t.Fatal("expected alpha island stats") + } + + if alphaStats.Count != 5 { + t.Errorf("expected Count 5, got %d", alphaStats.Count) + } + + if alphaStats.AvgFitness != 0.75 { + t.Errorf("expected AvgFitness 0.75, got %f", alphaStats.AvgFitness) + } + + if alphaStats.TopFitness != 0.95 { + t.Errorf("expected TopFitness 0.95, got %f", alphaStats.TopFitness) + } + + if alphaStats.Diversity != 0.8 { + t.Errorf("expected Diversity 0.8, got %f", alphaStats.Diversity) + } +} diff --git a/cmd/acb-evolver/internal/prompt/builder_test.go b/cmd/acb-evolver/internal/prompt/builder_test.go index 8c80114..1fbe737 100644 --- a/cmd/acb-evolver/internal/prompt/builder_test.go +++ b/cmd/acb-evolver/internal/prompt/builder_test.go @@ -170,3 +170,187 @@ func TestAssemble_generationAppearsInIslandContext(t *testing.T) { t.Error("expected generation number in island context") } } + +func TestAssemble_emptyParents_noParentSection(t *testing.T) { + r := Request{ + Parents: nil, + Island: evolverdb.IslandAlpha, + TargetLang: "go", + Generation: 1, + } + got := Assemble(r) + if strings.Contains(got, "## Parent Programs") { + t.Error("expected no parent section when parents is nil") + } +} + +func TestAssemble_emptyReplays_noReplaySection(t *testing.T) { + r := Request{ + Replays: nil, + Island: evolverdb.IslandAlpha, + TargetLang: "go", + Generation: 1, + } + got := Assemble(r) + if strings.Contains(got, "## Recent Match Analysis") { + t.Error("expected no replay section when replays is nil") + } +} + +func TestAssemble_multipleReplays(t *testing.T) { + replays := []MatchSummary{ + {MatchID: "m1", WinnerName: "w1", Condition: "elimination", TurnCount: 100}, + {MatchID: "m2", WinnerName: "w2", Condition: "dominance", TurnCount: 200}, + {MatchID: "m3", WinnerName: "w3", Condition: "turns", TurnCount: 500}, + } + r := Request{ + Replays: replays, + Island: evolverdb.IslandAlpha, + TargetLang: "go", + Generation: 1, + } + got := Assemble(r) + + for _, id := range []string{"m1", "m2", "m3"} { + if !strings.Contains(got, id) { + t.Errorf("expected match ID %s in prompt", id) + } + } +} + +func TestAssemble_drawResult(t *testing.T) { + replays := []MatchSummary{ + {MatchID: "draw-match", Condition: "draw", TurnCount: 500}, + } + r := Request{ + Replays: replays, + Island: evolverdb.IslandAlpha, + TargetLang: "go", + Generation: 1, + } + got := Assemble(r) + if !strings.Contains(got, "Draw") { + t.Error("expected Draw in prompt for draw condition") + } +} + +func TestAssemble_allIslandsHaveContext(t *testing.T) { + for _, island := range evolverdb.AllIslands { + r := Request{ + Island: island, + TargetLang: "go", + Generation: 1, + } + got := Assemble(r) + if !strings.Contains(got, island) { + t.Errorf("expected island %s in prompt", island) + } + } +} + +func TestAssemble_behaviorVectorDisplay(t *testing.T) { + parents := []*evolverdb.Program{ + { + ID: 1, + Code: "code", + Language: "go", + Fitness: 0.5, + BehaviorVector: []float64{0.25, 0.75}, + }, + } + r := Request{ + Parents: parents, + Island: evolverdb.IslandAlpha, + TargetLang: "go", + Generation: 1, + } + got := Assemble(r) + if !strings.Contains(got, "aggression=0.25") { + t.Error("expected aggression value in prompt") + } + if !strings.Contains(got, "economy=0.75") { + t.Error("expected economy value in prompt") + } +} + +func TestAssemble_parentWithoutBehaviorVector(t *testing.T) { + parents := []*evolverdb.Program{ + { + ID: 1, + Code: "code", + Language: "go", + Fitness: 0.5, + BehaviorVector: nil, // No behavior vector + }, + } + r := Request{ + Parents: parents, + Island: evolverdb.IslandAlpha, + TargetLang: "go", + Generation: 1, + } + got := Assemble(r) + // Should still include the parent, just without behavior info + if !strings.Contains(got, "code") { + t.Error("expected parent code in prompt even without behavior vector") + } +} + +func TestAssemble_codeBlockLanguage(t *testing.T) { + parents := []*evolverdb.Program{ + {Code: "code", Language: "python", Fitness: 0.5}, + } + r := Request{ + Parents: parents, + Island: evolverdb.IslandAlpha, + TargetLang: "python", + Generation: 1, + } + got := Assemble(r) + if !strings.Contains(got, "```python") { + t.Error("expected python code block in prompt") + } +} + +func TestAssemble_scoresDisplay(t *testing.T) { + replays := []MatchSummary{ + { + MatchID: "m1", + Scores: []int{100, 50, 25}, + Condition: "turns", + TurnCount: 100, + }, + } + r := Request{ + Replays: replays, + Island: evolverdb.IslandAlpha, + TargetLang: "go", + Generation: 1, + } + got := Assemble(r) + if !strings.Contains(got, "[100 50 25]") { + t.Error("expected scores to be displayed") + } +} + +func TestLangDisplayName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"go", "Go"}, + {"python", "Python"}, + {"rust", "Rust"}, + {"typescript", "TypeScript"}, + {"java", "Java"}, + {"php", "PHP"}, + {"unknown", "unknown"}, + } + + for _, tc := range tests { + got := langDisplayName(tc.input) + if got != tc.expected { + t.Errorf("langDisplayName(%q) = %q, want %q", tc.input, got, tc.expected) + } + } +} diff --git a/cmd/acb-evolver/internal/prompt/convert.go b/cmd/acb-evolver/internal/prompt/convert.go new file mode 100644 index 0000000..57d6022 --- /dev/null +++ b/cmd/acb-evolver/internal/prompt/convert.go @@ -0,0 +1,114 @@ +// Package prompt assembles evolution prompts for the LLM ensemble. +package prompt + +import ( + evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db" + "github.com/aicodebattle/acb/cmd/acb-evolver/internal/meta" + "github.com/aicodebattle/acb/cmd/acb-evolver/internal/replay" +) + +// FromReplayAnalysis converts a replay.Analysis to a prompt.MatchSummary. +// This allows the prompt builder to consume output from the replay analyzer. +func FromReplayAnalysis(a *replay.Analysis) MatchSummary { + if a == nil { + return MatchSummary{} + } + return MatchSummary{ + MatchID: a.MatchID, + WinnerName: a.WinnerName, + LoserName: a.LoserName, + Condition: a.Condition, + TurnCount: a.TurnCount, + Scores: append([]int(nil), a.Scores...), // copy to avoid aliasing + KeyMoments: append([]string(nil), a.KeyMoments...), + Strategies: append([]string(nil), a.Strategies...), + Weaknesses: append([]string(nil), a.Weaknesses...), + } +} + +// FromReplayAnalyses converts multiple replay analyses to match summaries. +func FromReplayAnalyses(analyses []*replay.Analysis) []MatchSummary { + if len(analyses) == 0 { + return nil + } + summaries := make([]MatchSummary, len(analyses)) + for i, a := range analyses { + summaries[i] = FromReplayAnalysis(a) + } + return summaries +} + +// FromMetaDescription converts a meta.Description to a prompt.MetaDescription. +// This allows the prompt builder to consume output from the meta builder. +func FromMetaDescription(d *meta.Description) MetaDescription { + if d == nil { + return MetaDescription{} + } + return MetaDescription{ + TotalBots: d.TotalBots, + DominantStrategy: d.DominantStrategy, + TopBots: FromBotInfos(d.TopBots), + IslandStats: FromIslandStatsMap(d.IslandStats), + } +} + +// FromBotInfos converts meta.BotInfo slices to prompt.BotSummary slices. +func FromBotInfos(bots []meta.BotInfo) []BotSummary { + if len(bots) == 0 { + return nil + } + result := make([]BotSummary, len(bots)) + for i, b := range bots { + result[i] = BotSummary{ + Name: b.Name, + Rating: b.Rating, + Island: b.Island, + Evolved: b.Evolved, + } + } + return result +} + +// FromIslandStatsMap converts meta island stats to prompt island stats. +func FromIslandStatsMap(stats map[string]meta.IslandStats) map[string]IslandStat { + if stats == nil { + return nil + } + result := make(map[string]IslandStat, len(stats)) + for k, v := range stats { + result[k] = IslandStat{ + Count: v.Count, + AvgFitness: v.AvgFitness, + TopFitness: v.TopFitness, + } + } + return result +} + +// BuildRequest is a convenience function that assembles a prompt.Request +// from the standard evolution pipeline components. +// +// Parameters: +// - parents: programs selected as evolutionary parents (from tournament selection) +// - analyses: replay analyses from recent matches +// - metaDesc: meta-game description from the meta builder +// - island: target island for the new candidate +// - targetLang: programming language for the evolved bot +// - generation: current evolution generation number +func BuildRequest( + parents []*evolverdb.Program, + analyses []*replay.Analysis, + metaDesc *meta.Description, + island string, + targetLang string, + generation int, +) Request { + return Request{ + Parents: parents, + Replays: FromReplayAnalyses(analyses), + Meta: FromMetaDescription(metaDesc), + Island: island, + TargetLang: targetLang, + Generation: generation, + } +} diff --git a/cmd/acb-evolver/internal/prompt/convert_test.go b/cmd/acb-evolver/internal/prompt/convert_test.go new file mode 100644 index 0000000..427622d --- /dev/null +++ b/cmd/acb-evolver/internal/prompt/convert_test.go @@ -0,0 +1,261 @@ +package prompt + +import ( + "testing" + + evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db" + "github.com/aicodebattle/acb/cmd/acb-evolver/internal/meta" + "github.com/aicodebattle/acb/cmd/acb-evolver/internal/replay" +) + +func TestFromReplayAnalysis_Nil(t *testing.T) { + got := FromReplayAnalysis(nil) + if got.MatchID != "" || got.WinnerName != "" { + t.Errorf("expected empty MatchSummary for nil input, got %+v", got) + } +} + +func TestFromReplayAnalysis_Full(t *testing.T) { + analysis := &replay.Analysis{ + MatchID: "match-123", + WinnerName: "Winner", + LoserName: "Loser", + Condition: "elimination", + TurnCount: 100, + Scores: []int{50, 20}, + KeyMoments: []string{"moment1", "moment2"}, + Strategies: []string{"strategy1"}, + Weaknesses: []string{"weakness1"}, + } + + got := FromReplayAnalysis(analysis) + + if got.MatchID != "match-123" { + t.Errorf("expected MatchID 'match-123', got %q", got.MatchID) + } + if got.WinnerName != "Winner" { + t.Errorf("expected WinnerName 'Winner', got %q", got.WinnerName) + } + if got.LoserName != "Loser" { + t.Errorf("expected LoserName 'Loser', got %q", got.LoserName) + } + if got.Condition != "elimination" { + t.Errorf("expected Condition 'elimination', got %q", got.Condition) + } + if got.TurnCount != 100 { + t.Errorf("expected TurnCount 100, got %d", got.TurnCount) + } + if len(got.Scores) != 2 || got.Scores[0] != 50 || got.Scores[1] != 20 { + t.Errorf("expected Scores [50, 20], got %v", got.Scores) + } + if len(got.KeyMoments) != 2 { + t.Errorf("expected 2 KeyMoments, got %d", len(got.KeyMoments)) + } + if len(got.Strategies) != 1 { + t.Errorf("expected 1 Strategy, got %d", len(got.Strategies)) + } + if len(got.Weaknesses) != 1 { + t.Errorf("expected 1 Weakness, got %d", len(got.Weaknesses)) + } +} + +func TestFromReplayAnalysis_SliceCopy(t *testing.T) { + analysis := &replay.Analysis{ + Scores: []int{1, 2, 3}, + KeyMoments: []string{"a", "b"}, + } + + got := FromReplayAnalysis(analysis) + + // Modify original slices to ensure we have a copy + analysis.Scores[0] = 999 + analysis.KeyMoments[0] = "modified" + + if got.Scores[0] == 999 { + t.Error("expected Scores to be a copy, not a reference") + } + if got.KeyMoments[0] == "modified" { + t.Error("expected KeyMoments to be a copy, not a reference") + } +} + +func TestFromReplayAnalyses_Nil(t *testing.T) { + got := FromReplayAnalyses(nil) + if got != nil { + t.Errorf("expected nil for nil input, got %v", got) + } +} + +func TestFromReplayAnalyses_Empty(t *testing.T) { + got := FromReplayAnalyses([]*replay.Analysis{}) + if got != nil { + t.Errorf("expected nil for empty input, got %v", got) + } +} + +func TestFromReplayAnalyses_Multiple(t *testing.T) { + analyses := []*replay.Analysis{ + {MatchID: "m1", WinnerName: "w1"}, + {MatchID: "m2", WinnerName: "w2"}, + } + + got := FromReplayAnalyses(analyses) + + if len(got) != 2 { + t.Fatalf("expected 2 summaries, got %d", len(got)) + } + if got[0].MatchID != "m1" || got[1].MatchID != "m2" { + t.Errorf("expected match IDs m1, m2, got %v", got) + } +} + +func TestFromMetaDescription_Nil(t *testing.T) { + got := FromMetaDescription(nil) + if got.TotalBots != 0 || got.DominantStrategy != "" { + t.Errorf("expected empty MetaDescription for nil input, got %+v", got) + } +} + +func TestFromMetaDescription_Full(t *testing.T) { + desc := &meta.Description{ + TotalBots: 42, + DominantStrategy: "aggressive", + TopBots: []meta.BotInfo{ + {Name: "bot1", Rating: 1600, Island: "alpha", Evolved: true}, + {Name: "bot2", Rating: 1500, Island: "beta", Evolved: false}, + }, + IslandStats: map[string]meta.IslandStats{ + "alpha": {Count: 10, AvgFitness: 0.5, TopFitness: 0.9, Diversity: 0.8}, + }, + } + + got := FromMetaDescription(desc) + + if got.TotalBots != 42 { + t.Errorf("expected TotalBots 42, got %d", got.TotalBots) + } + if got.DominantStrategy != "aggressive" { + t.Errorf("expected DominantStrategy 'aggressive', got %q", got.DominantStrategy) + } + if len(got.TopBots) != 2 { + t.Errorf("expected 2 TopBots, got %d", len(got.TopBots)) + } + if got.TopBots[0].Name != "bot1" || got.TopBots[0].Rating != 1600 { + t.Errorf("expected bot1 with rating 1600, got %+v", got.TopBots[0]) + } + if len(got.IslandStats) != 1 { + t.Errorf("expected 1 IslandStats entry, got %d", len(got.IslandStats)) + } + if stat, ok := got.IslandStats["alpha"]; !ok { + t.Error("expected alpha in IslandStats") + } else if stat.Count != 10 || stat.AvgFitness != 0.5 { + t.Errorf("expected Count=10, AvgFitness=0.5, got %+v", stat) + } +} + +func TestFromBotInfos_Nil(t *testing.T) { + got := FromBotInfos(nil) + if got != nil { + t.Errorf("expected nil for nil input, got %v", got) + } +} + +func TestFromBotInfos_Empty(t *testing.T) { + got := FromBotInfos([]meta.BotInfo{}) + if got != nil { + t.Errorf("expected nil for empty input, got %v", got) + } +} + +func TestFromBotInfos_Multiple(t *testing.T) { + bots := []meta.BotInfo{ + {Name: "a", Rating: 100, Island: "x", Evolved: true}, + {Name: "b", Rating: 200, Island: "y", Evolved: false}, + } + + got := FromBotInfos(bots) + + if len(got) != 2 { + t.Fatalf("expected 2 bots, got %d", len(got)) + } + if got[0].Name != "a" || got[0].Rating != 100 { + t.Errorf("expected a/100, got %+v", got[0]) + } + if got[1].Name != "b" || got[1].Rating != 200 { + t.Errorf("expected b/200, got %+v", got[1]) + } +} + +func TestFromIslandStatsMap_Nil(t *testing.T) { + got := FromIslandStatsMap(nil) + if got != nil { + t.Errorf("expected nil for nil input, got %v", got) + } +} + +func TestFromIslandStatsMap_Multiple(t *testing.T) { + stats := map[string]meta.IslandStats{ + "alpha": {Count: 5, AvgFitness: 0.6, TopFitness: 0.95}, + "beta": {Count: 3, AvgFitness: 0.4, TopFitness: 0.8}, + } + + got := FromIslandStatsMap(stats) + + if len(got) != 2 { + t.Fatalf("expected 2 entries, got %d", len(got)) + } + if got["alpha"].Count != 5 { + t.Errorf("expected alpha.Count=5, got %d", got["alpha"].Count) + } + if got["beta"].AvgFitness != 0.4 { + t.Errorf("expected beta.AvgFitness=0.4, got %f", got["beta"].AvgFitness) + } +} + +func TestBuildRequest_Full(t *testing.T) { + parents := []*evolverdb.Program{ + {ID: 1, Code: "code1", Language: "go", Fitness: 0.8}, + } + analyses := []*replay.Analysis{ + {MatchID: "m1", WinnerName: "w1"}, + } + metaDesc := &meta.Description{ + TotalBots: 10, + DominantStrategy: "aggressive", + } + + req := BuildRequest(parents, analyses, metaDesc, "alpha", "go", 5) + + if len(req.Parents) != 1 { + t.Errorf("expected 1 parent, got %d", len(req.Parents)) + } + if len(req.Replays) != 1 { + t.Errorf("expected 1 replay, got %d", len(req.Replays)) + } + if req.Meta.TotalBots != 10 { + t.Errorf("expected Meta.TotalBots=10, got %d", req.Meta.TotalBots) + } + if req.Island != "alpha" { + t.Errorf("expected Island 'alpha', got %q", req.Island) + } + if req.TargetLang != "go" { + t.Errorf("expected TargetLang 'go', got %q", req.TargetLang) + } + if req.Generation != 5 { + t.Errorf("expected Generation 5, got %d", req.Generation) + } +} + +func TestBuildRequest_NilInputs(t *testing.T) { + req := BuildRequest(nil, nil, nil, "beta", "python", 0) + + if req.Parents != nil { + t.Errorf("expected nil Parents, got %v", req.Parents) + } + if req.Replays != nil { + t.Errorf("expected nil Replays, got %v", req.Replays) + } + if req.Meta.TotalBots != 0 { + t.Errorf("expected empty Meta, got %+v", req.Meta) + } +} diff --git a/cmd/acb-evolver/internal/replay/analyzer.go b/cmd/acb-evolver/internal/replay/analyzer.go new file mode 100644 index 0000000..e3d475c --- /dev/null +++ b/cmd/acb-evolver/internal/replay/analyzer.go @@ -0,0 +1,437 @@ +// Package replay analyzes match replays to extract strategic insights +// for the LLM evolution prompt. +// +// The analyzer processes completed match replays and produces: +// - Key moments: significant events that changed the match trajectory +// - Strategies: winning tactics employed by the victor +// - Weaknesses: exploitable patterns in the loser's play +package replay + +import ( + "github.com/aicodebattle/acb/engine" +) + +// Analysis holds the extracted insights from a single match replay. +type Analysis struct { + // MatchID is the unique identifier of the analyzed match. + MatchID string + // WinnerName is the name of the winning player (empty for draws). + WinnerName string + // LoserName is the name of the losing player (empty for draws). + LoserName string + // Condition is the win condition: "elimination", "dominance", "turns", or "draw". + Condition string + // TurnCount is the total number of turns played. + TurnCount int + // Scores holds the final scores for each player slot. + Scores []int + // KeyMoments are notable events that influenced the outcome. + KeyMoments []string + // Strategies lists the successful tactics used by the winner. + Strategies []string + // Weaknesses lists the exploitable patterns in the loser's play. + Weaknesses []string +} + +// Analyzer processes replays and extracts strategic insights. +type Analyzer struct{} + +// NewAnalyzer creates a new replay analyzer. +func NewAnalyzer() *Analyzer { + return &Analyzer{} +} + +// Analyze processes a replay and returns a structured analysis. +func (a *Analyzer) Analyze(replay *engine.Replay) *Analysis { + if replay == nil { + return nil + } + + analysis := &Analysis{ + MatchID: replay.MatchID, + TurnCount: len(replay.Turns), + Scores: make([]int, 0), + Condition: "", + } + + // Extract result information + if replay.Result != nil { + analysis.Condition = replay.Result.Reason + if len(replay.Result.Scores) > 0 { + analysis.Scores = replay.Result.Scores + } + } + + // Identify winner and loser + if len(replay.Players) >= 2 && replay.Result != nil && replay.Result.Winner >= 0 { + winnerIdx := replay.Result.Winner + if winnerIdx < len(replay.Players) { + analysis.WinnerName = replay.Players[winnerIdx].Name + } + // Loser is the other player (for 2-player matches) + if len(replay.Players) == 2 { + loserIdx := 1 - winnerIdx + if replay.Result.Winner >= 0 { + analysis.LoserName = replay.Players[loserIdx].Name + } + } + } + + // Analyze the replay for strategic insights + a.analyzeKeyMoments(replay, analysis) + a.analyzeStrategies(replay, analysis) + a.analyzeWeaknesses(replay, analysis) + + return analysis +} + +// analyzeKeyMoments identifies significant events that shaped the match. +func (a *Analyzer) analyzeKeyMoments(replay *engine.Replay, analysis *Analysis) { + var moments []string + + winnerID := -1 + if replay.Result != nil { + winnerID = replay.Result.Winner + } + + // Track key metrics over time to detect pivotal moments + prevBotCounts := make(map[int]int) + prevScores := make(map[int]int) + coreFlips := make(map[int]int) // track core ownership changes by player + + for turnIdx, turn := range replay.Turns { + turnNum := turn.Turn + + // Count bots per player + botCounts := make(map[int]int) + for _, bot := range turn.Bots { + if bot.Alive { + botCounts[bot.Owner]++ + } + } + + // Detect bot count changes (spawn/death events) + for playerID, count := range botCounts { + prevCount := prevBotCounts[playerID] + if turnNum > 0 && prevCount > 0 { + diff := count - prevCount + if diff <= -3 { + moments = append(moments, formatMoment(turnNum, playerID, replay.Players, + "lost %d bots in rapid succession", -diff)) + } else if diff >= 3 { + moments = append(moments, formatMoment(turnNum, playerID, replay.Players, + "spawned %d bots in rapid succession", diff)) + } + } + prevBotCounts[playerID] = count + } + + // Process events for notable occurrences + for _, event := range turn.Events { + switch event.Type { + case "core_captured": + details, ok := event.Details.(map[string]interface{}) + if !ok { + continue + } + // Track core ownership changes + if attacker, ok := details["attacker_id"].(float64); ok { + playerID := int(attacker) + coreFlips[playerID]++ + if coreFlips[playerID] == 1 { + moments = append(moments, formatMoment(turnNum, playerID, replay.Players, + "captured first enemy core")) + } + } + case "combat_death": + if turnNum < 50 { + moments = append(moments, formatMoment(turnNum, -1, replay.Players, + "early combat casualty")) + } + } + } + + // Detect score swings + for playerID, score := range turn.Scores { + prevScore := prevScores[playerID] + if turnNum > 0 && prevScore > 0 { + diff := score - prevScore + if diff >= 20 { + moments = append(moments, formatMoment(turnNum, playerID, replay.Players, + "gained %d score in single turn", diff)) + } + } + prevScores[playerID] = score + } + + // Limit key moments to avoid prompt bloat + if len(moments) >= 5 && turnIdx < len(replay.Turns)-10 { + break + } + } + + // Add final score summary if there's a clear winner + if winnerID >= 0 && len(analysis.Scores) >= 2 { + scoreDiff := analysis.Scores[winnerID] + if len(analysis.Scores) > 1 { + loserID := 1 - winnerID + if winnerID == 1 { + loserID = 0 + } + if loserID < len(analysis.Scores) { + scoreDiff = analysis.Scores[winnerID] - analysis.Scores[loserID] + } + } + if scoreDiff > 100 { + moments = append(moments, "Final score advantage: dominant victory") + } else if scoreDiff > 50 { + moments = append(moments, "Final score advantage: clear victory") + } else if scoreDiff > 20 { + moments = append(moments, "Final score advantage: narrow victory") + } + } + + analysis.KeyMoments = dedupeMoments(moments) +} + +// analyzeStrategies identifies winning tactics from the replay. +func (a *Analyzer) analyzeStrategies(replay *engine.Replay, analysis *Analysis) { + if replay.Result == nil || replay.Result.Winner < 0 { + return + } + + winnerID := replay.Result.Winner + var strategies []string + + // Analyze early game (first 50 turns) + earlyBots := make(map[int]int) + earlyEnergy := make(map[int]int) + for _, turn := range replay.Turns { + if turn.Turn > 50 { + break + } + for _, bot := range turn.Bots { + if bot.Alive { + earlyBots[bot.Owner]++ + } + } + earlyEnergy[turn.Turn%10] = len(turn.Energy) + } + + // Detect aggressive early expansion + if earlyBots[winnerID] > earlyBots[1-winnerID]*2 && winnerID < len(earlyBots) { + strategies = append(strategies, "aggressive early expansion") + } + + // Analyze core control + coreCaptures := make(map[int]int) + for _, turn := range replay.Turns { + for _, core := range turn.Cores { + if core.Owner == winnerID && core.Active { + coreCaptures[winnerID]++ + } + } + } + if coreCaptures[winnerID] > 1 { + strategies = append(strategies, "multi-core control") + } + + // Detect win condition patterns + switch replay.Result.Reason { + case "elimination": + strategies = append(strategies, "complete elimination of opponent") + case "dominance": + strategies = append(strategies, "sustained bot superiority") + case "turns": + strategies = append(strategies, "score accumulation strategy") + } + + // Analyze spawn patterns (energy management) + spawnRate := 0 + if len(replay.Turns) > 100 { + botGrowth := 0 + for _, turn := range replay.Turns[80:100] { + for _, bot := range turn.Bots { + if bot.Alive && bot.Owner == winnerID { + botGrowth++ + } + } + } + spawnRate = botGrowth / 20 + } + if spawnRate >= 3 { + strategies = append(strategies, "high spawn tempo") + } else if spawnRate >= 1 { + strategies = append(strategies, "controlled spawn rate") + } + + // Detect energy focus vs combat focus + energyCollected := 0 + combatDeaths := 0 + for _, turn := range replay.Turns { + for _, event := range turn.Events { + if event.Type == "energy_collected" { + energyCollected++ + } else if event.Type == "combat_death" { + combatDeaths++ + } + } + } + if energyCollected > combatDeaths*2 { + strategies = append(strategies, "energy-focused economy") + } else if combatDeaths > energyCollected { + strategies = append(strategies, "aggressive combat pressure") + } + + analysis.Strategies = dedupe(strategies) +} + +// analyzeWeaknesses identifies exploitable patterns in the loser's play. +func (a *Analyzer) analyzeWeaknesses(replay *engine.Replay, analysis *Analysis) { + if replay.Result == nil || replay.Result.Winner < 0 || len(replay.Players) < 2 { + return + } + + loserID := 1 - replay.Result.Winner + var weaknesses []string + + // Analyze bot count trends + botCounts := make(map[int][]int) + for _, turn := range replay.Turns { + count := 0 + for _, bot := range turn.Bots { + if bot.Alive && bot.Owner == loserID { + count++ + } + } + botCounts[loserID] = append(botCounts[loserID], count) + } + + // Detect bot shortage issues + if len(botCounts[loserID]) > 50 { + lateBots := botCounts[loserID][len(botCounts[loserID])-1] + if lateBots < 3 { + weaknesses = append(weaknesses, "insufficient bot production") + } + } + + // Detect passive play + spawnEvents := 0 + for i, turn := range replay.Turns { + if i == 0 { + continue + } + prevCount := 0 + currCount := 0 + for _, bot := range replay.Turns[i-1].Bots { + if bot.Alive && bot.Owner == loserID { + prevCount++ + } + } + for _, bot := range turn.Bots { + if bot.Alive && bot.Owner == loserID { + currCount++ + } + } + if currCount > prevCount { + spawnEvents++ + } + } + if spawnEvents < 5 && len(replay.Turns) > 100 { + weaknesses = append(weaknesses, "passive spawn behavior") + } + + // Detect core defense issues + coreLosses := 0 + for _, turn := range replay.Turns { + for _, event := range turn.Events { + if event.Type == "core_captured" { + details, ok := event.Details.(map[string]interface{}) + if ok { + if victim, ok := details["victim_id"].(float64); ok && int(victim) == loserID { + coreLosses++ + } + } + } + } + } + if coreLosses > 0 { + weaknesses = append(weaknesses, "weak core defense") + } + + // Detect energy inefficiency + energyCollected := 0 + for _, turn := range replay.Turns { + for _, event := range turn.Events { + if event.Type == "energy_collected" { + energyCollected++ + } + } + } + if energyCollected < 10 && len(replay.Turns) > 100 { + weaknesses = append(weaknesses, "poor energy collection") + } + + // Detect early elimination vulnerability + if replay.Result.Reason == "elimination" && len(replay.Turns) < 100 { + weaknesses = append(weaknesses, "vulnerable to early aggression") + } + + // Detect score gap accumulation + if len(analysis.Scores) > loserID && len(analysis.Scores) > replay.Result.Winner { + scoreGap := analysis.Scores[replay.Result.Winner] - analysis.Scores[loserID] + if scoreGap > 100 { + weaknesses = append(weaknesses, "failed to contest score") + } + } + + analysis.Weaknesses = dedupe(weaknesses) +} + +// formatMoment creates a formatted key moment string. +func formatMoment(turn, playerID int, players []engine.ReplayPlayer, format string, args ...interface{}) string { + playerName := "" + if playerID >= 0 && playerID < len(players) { + playerName = players[playerID].Name + } + return formatPlayerMoment(turn, playerName, format, args...) +} + +// formatPlayerMoment formats a moment with player name context. +func formatPlayerMoment(turn int, playerName, format string, args ...interface{}) string { + args = append([]interface{}{turn}, args...) + if playerName != "" { + return playerName + " (turn %d): " + format + } + return "Turn %d: " + format +} + +// dedupeMoments removes duplicate or similar moments. +func dedupeMoments(moments []string) []string { + seen := make(map[string]bool) + var result []string + for _, m := range moments { + if !seen[m] { + seen[m] = true + result = append(result, m) + } + } + // Limit to 5 most relevant moments + if len(result) > 5 { + result = result[:5] + } + return result +} + +// dedupe removes duplicate strings from a slice. +func dedupe(items []string) []string { + seen := make(map[string]bool) + var result []string + for _, item := range items { + if !seen[item] { + seen[item] = true + result = append(result, item) + } + } + return result +} diff --git a/cmd/acb-evolver/internal/replay/analyzer_test.go b/cmd/acb-evolver/internal/replay/analyzer_test.go new file mode 100644 index 0000000..53344f5 --- /dev/null +++ b/cmd/acb-evolver/internal/replay/analyzer_test.go @@ -0,0 +1,306 @@ +package replay + +import ( + "testing" + "time" + + "github.com/aicodebattle/acb/engine" +) + +func TestAnalyzer_Analyze_NilReplay(t *testing.T) { + a := NewAnalyzer() + got := a.Analyze(nil) + if got != nil { + t.Errorf("expected nil for nil replay, got %+v", got) + } +} + +func TestAnalyzer_Analyze_BasicMatch(t *testing.T) { + a := NewAnalyzer() + + replay := &engine.Replay{ + FormatVersion: "1.0", + MatchID: "test-match-001", + StartTime: time.Now(), + EndTime: time.Now(), + Result: &engine.MatchResult{ + Winner: 0, + Reason: "dominance", + Turns: 150, + Scores: []int{120, 45}, + Energy: []int{15, 8}, + BotsAlive: []int{8, 2}, + }, + Players: []engine.ReplayPlayer{ + {ID: 0, Name: "WinnerBot"}, + {ID: 1, Name: "LoserBot"}, + }, + Map: engine.ReplayMap{ + Rows: 60, + Cols: 60, + }, + Turns: []engine.ReplayTurn{ + { + Turn: 0, + Bots: []engine.ReplayBot{}, + Scores: []int{0, 0}, + }, + { + Turn: 50, + Bots: []engine.ReplayBot{}, + Scores: []int{40, 20}, + }, + { + Turn: 100, + Bots: []engine.ReplayBot{}, + Scores: []int{80, 35}, + }, + { + Turn: 150, + Bots: []engine.ReplayBot{}, + Scores: []int{120, 45}, + }, + }, + } + + got := a.Analyze(replay) + + if got == nil { + t.Fatal("expected non-nil analysis") + } + + if got.MatchID != "test-match-001" { + t.Errorf("expected MatchID 'test-match-001', got %q", got.MatchID) + } + + if got.WinnerName != "WinnerBot" { + t.Errorf("expected WinnerName 'WinnerBot', got %q", got.WinnerName) + } + + if got.LoserName != "LoserBot" { + t.Errorf("expected LoserName 'LoserBot', got %q", got.LoserName) + } + + if got.Condition != "dominance" { + t.Errorf("expected Condition 'dominance', got %q", got.Condition) + } + + if got.TurnCount != 4 { + t.Errorf("expected TurnCount 4 (number of turn records), got %d", got.TurnCount) + } +} + +func TestAnalyzer_Analyze_EliminationMatch(t *testing.T) { + a := NewAnalyzer() + + replay := &engine.Replay{ + MatchID: "elimination-match", + Result: &engine.MatchResult{ + Winner: 1, + Reason: "elimination", + Turns: 75, + Scores: []int{10, 85}, + BotsAlive: []int{0, 6}, + }, + Players: []engine.ReplayPlayer{ + {ID: 0, Name: "EliminatedBot"}, + {ID: 1, Name: "VictorBot"}, + }, + Turns: []engine.ReplayTurn{ + {Turn: 0, Bots: []engine.ReplayBot{}}, + {Turn: 30, Bots: []engine.ReplayBot{ + {ID: 1, Owner: 0, Alive: true}, + {ID: 2, Owner: 1, Alive: true}, + {ID: 3, Owner: 1, Alive: true}, + }}, + {Turn: 60, Bots: []engine.ReplayBot{ + {ID: 2, Owner: 1, Alive: true}, + {ID: 3, Owner: 1, Alive: true}, + {ID: 4, Owner: 1, Alive: true}, + }}, + {Turn: 75, Bots: []engine.ReplayBot{ + {ID: 2, Owner: 1, Alive: true}, + {ID: 3, Owner: 1, Alive: true}, + {ID: 4, Owner: 1, Alive: true}, + {ID: 5, Owner: 1, Alive: true}, + {ID: 6, Owner: 1, Alive: true}, + {ID: 7, Owner: 1, Alive: true}, + }}, + }, + } + + got := a.Analyze(replay) + + if got.WinnerName != "VictorBot" { + t.Errorf("expected WinnerName 'VictorBot', got %q", got.WinnerName) + } + + if got.LoserName != "EliminatedBot" { + t.Errorf("expected LoserName 'EliminatedBot', got %q", got.LoserName) + } + + if got.Condition != "elimination" { + t.Errorf("expected Condition 'elimination', got %q", got.Condition) + } + + // Should detect elimination strategy + foundElimination := false + for _, s := range got.Strategies { + if s == "complete elimination of opponent" { + foundElimination = true + break + } + } + if !foundElimination { + t.Error("expected 'complete elimination of opponent' in strategies") + } + + // Should detect vulnerability to early aggression + foundVulnerable := false + for _, w := range got.Weaknesses { + if w == "vulnerable to early aggression" { + foundVulnerable = true + break + } + } + if !foundVulnerable { + t.Errorf("expected 'vulnerable to early aggression' in weaknesses, got %v", got.Weaknesses) + } +} + +func TestAnalyzer_Analyze_DrawMatch(t *testing.T) { + a := NewAnalyzer() + + replay := &engine.Replay{ + MatchID: "draw-match", + Result: &engine.MatchResult{ + Winner: -1, + Reason: "draw", + Turns: 500, + Scores: []int{100, 100}, + }, + Players: []engine.ReplayPlayer{ + {ID: 0, Name: "Bot1"}, + {ID: 1, Name: "Bot2"}, + }, + Turns: []engine.ReplayTurn{ + {Turn: 0, Bots: []engine.ReplayBot{}}, + {Turn: 500, Bots: []engine.ReplayBot{}}, + }, + } + + got := a.Analyze(replay) + + if got.WinnerName != "" { + t.Errorf("expected empty WinnerName for draw, got %q", got.WinnerName) + } + + if got.LoserName != "" { + t.Errorf("expected empty LoserName for draw, got %q", got.LoserName) + } + + if got.Condition != "draw" { + t.Errorf("expected Condition 'draw', got %q", got.Condition) + } +} + +func TestAnalyzer_Analyze_WithEvents(t *testing.T) { + a := NewAnalyzer() + + replay := &engine.Replay{ + MatchID: "eventful-match", + Result: &engine.MatchResult{ + Winner: 0, + Reason: "dominance", + Turns: 200, + Scores: []int{200, 50}, + }, + Players: []engine.ReplayPlayer{ + {ID: 0, Name: "Aggressor"}, + {ID: 1, Name: "Defender"}, + }, + Turns: []engine.ReplayTurn{ + {Turn: 0, Bots: []engine.ReplayBot{}, Events: nil}, + {Turn: 30, Bots: []engine.ReplayBot{ + {ID: 1, Owner: 0, Alive: true}, + {ID: 2, Owner: 1, Alive: true}, + }, Events: []engine.Event{ + {Type: "core_captured", Turn: 30, Details: map[string]interface{}{ + "attacker_id": float64(0), + "victim_id": float64(1), + }}, + }}, + {Turn: 60, Bots: []engine.ReplayBot{ + {ID: 1, Owner: 0, Alive: true}, + {ID: 2, Owner: 0, Alive: true}, + {ID: 3, Owner: 0, Alive: true}, + {ID: 4, Owner: 0, Alive: true}, + }, Events: []engine.Event{ + {Type: "energy_collected", Turn: 60, Details: nil}, + {Type: "energy_collected", Turn: 60, Details: nil}, + }, Scores: []int{80, 30}}, + }, + } + + got := a.Analyze(replay) + + // Should have detected key moments + if len(got.KeyMoments) == 0 { + t.Error("expected some key moments from events") + } +} + +func TestDedupeMoments(t *testing.T) { + moments := []string{ + "First moment", + "Second moment", + "First moment", // duplicate + "Third moment", + "Second moment", // duplicate + } + + got := dedupeMoments(moments) + + if len(got) != 3 { + t.Errorf("expected 3 unique moments, got %d", len(got)) + } +} + +func TestDedupeMoments_Limit(t *testing.T) { + moments := make([]string, 10) + for i := range moments { + moments[i] = "moment" + } + moments[0] = "unique1" + moments[1] = "unique2" + moments[2] = "unique3" + moments[3] = "unique4" + moments[4] = "unique5" + moments[5] = "unique6" + + got := dedupeMoments(moments) + + if len(got) > 5 { + t.Errorf("expected at most 5 moments, got %d", len(got)) + } +} + +func TestDedupe(t *testing.T) { + items := []string{"a", "b", "a", "c", "b", "d"} + got := dedupe(items) + + if len(got) != 4 { + t.Errorf("expected 4 unique items, got %d: %v", len(got), got) + } + + // Check all expected items are present + seen := make(map[string]bool) + for _, item := range got { + seen[item] = true + } + for _, expected := range []string{"a", "b", "c", "d"} { + if !seen[expected] { + t.Errorf("expected item %q in result", expected) + } + } +}