feat(acb-evolver): add LLM prompt builder and ensemble integration

- Add parent sampling via tournament selection (selector/tournament.go)
- Add replay analyzer to extract key moments, strategies, weaknesses
- Add meta builder for leaderboard summary and dominant strategies
- Add prompt assembler combining parent code + replay + meta context
- Add LLM ensemble with fast tier (GLM-5-Turbo) for bulk generation
  and strong tier (GLM-5) for refinement passes
- Add code extraction from LLM responses with language validation
- Add convert utilities for type conversion between packages
- Comprehensive test coverage for all components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-29 16:47:25 -04:00
parent 26244d1066
commit f5924e8b15
10 changed files with 2553 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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