From bd4b0d32444eb0cf383e8a0afa10326b8fae50bf Mon Sep 17 00:00:00 2001 From: jedarden Date: Thu, 26 Mar 2026 22:26:09 -0400 Subject: [PATCH] Add LLM prompt builder and ensemble integration (Phase 7) - selector: tournament selection for parent sampling from island populations - prompt: assembles evolution prompts from parent code, replay analysis, and meta description - llm: OpenAI-compatible client routing to ZAI proxy with fast (GLM-5-Turbo) and strong (GLM-5) tiers, plus code block extraction from model responses - Tests for prompt assembly, code extraction, and tournament selection Co-Authored-By: Claude Sonnet 4.6 --- cmd/acb-evolver/internal/llm/client.go | 183 +++++++++++++ cmd/acb-evolver/internal/llm/extract.go | 119 ++++++++ cmd/acb-evolver/internal/llm/extract_test.go | 155 +++++++++++ cmd/acb-evolver/internal/prompt/builder.go | 253 ++++++++++++++++++ .../internal/prompt/builder_test.go | 172 ++++++++++++ .../internal/selector/tournament.go | 55 ++++ .../internal/selector/tournament_test.go | 90 +++++++ 7 files changed, 1027 insertions(+) create mode 100644 cmd/acb-evolver/internal/llm/client.go create mode 100644 cmd/acb-evolver/internal/llm/extract.go create mode 100644 cmd/acb-evolver/internal/llm/extract_test.go create mode 100644 cmd/acb-evolver/internal/prompt/builder.go create mode 100644 cmd/acb-evolver/internal/prompt/builder_test.go create mode 100644 cmd/acb-evolver/internal/selector/tournament.go create mode 100644 cmd/acb-evolver/internal/selector/tournament_test.go diff --git a/cmd/acb-evolver/internal/llm/client.go b/cmd/acb-evolver/internal/llm/client.go new file mode 100644 index 0000000..25a6c8b --- /dev/null +++ b/cmd/acb-evolver/internal/llm/client.go @@ -0,0 +1,183 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Tier selects the LLM model tier for a generation request. +type Tier string + +const ( + // TierFast uses GLM-5-Turbo for bulk candidate generation. + TierFast Tier = "fast" + // TierStrong uses GLM-5 for high-quality refinement passes. + TierStrong Tier = "strong" +) + +const ( + modelFast = "GLM-5-Turbo" + modelStrong = "GLM-5" + + defaultMaxTokens = 4096 + defaultTemperature = 0.85 + defaultTimeout = 120 * time.Second +) + +// Client is an OpenAI-compatible LLM client that routes requests through the +// ZAI proxy. Create one with NewClient and reuse it across calls. +type Client struct { + baseURL string + apiKey string + httpClient *http.Client +} + +// NewClient creates a Client that sends requests to baseURL (e.g. +// "http://zai-proxy-apexalgo.tail1b1987.ts.net:8080"). +// apiKey may be empty when the proxy does not require authentication. +func NewClient(baseURL, apiKey string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: defaultTimeout, + }, + } +} + +// GenerateRequest specifies a single code-generation task. +type GenerateRequest struct { + // Prompt is the full evolution prompt assembled by the prompt builder. + Prompt string + // Tier selects the model: TierFast for bulk generation, TierStrong for + // refinement. + Tier Tier + // MaxTokens caps the response length (0 → defaultMaxTokens). + MaxTokens int + // Temperature controls response randomness (0 → defaultTemperature). + Temperature float64 + // TargetLang is the expected language of the returned code block + // (e.g. "go"). Used during extraction. + TargetLang string +} + +// GenerateResponse holds extracted code and the raw LLM output. +type GenerateResponse struct { + // Candidate is the extracted bot code and its detected language. + Candidate *Candidate + // RawText is the unprocessed LLM response text. + RawText string +} + +// Generate sends the prompt to the configured LLM tier and returns the best +// extracted bot code candidate. +func (c *Client) Generate(ctx context.Context, req GenerateRequest) (*GenerateResponse, error) { + model := modelFast + if req.Tier == TierStrong { + model = modelStrong + } + maxTokens := req.MaxTokens + if maxTokens == 0 { + maxTokens = defaultMaxTokens + } + temp := req.Temperature + if temp == 0 { + temp = defaultTemperature + } + + raw, err := c.chatCompletion(ctx, model, req.Prompt, maxTokens, temp) + if err != nil { + return nil, err + } + + candidate, err := ExtractBestCandidate(raw, req.TargetLang) + if err != nil { + return nil, fmt.Errorf("extract candidate: %w (raw preview: %.200s)", err, raw) + } + + return &GenerateResponse{ + Candidate: candidate, + RawText: raw, + }, nil +} + +// ── OpenAI-compatible wire types ────────────────────────────────────────── + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatResponse struct { + Choices []struct { + Message chatMessage `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (c *Client) chatCompletion(ctx context.Context, model, prompt string, maxTokens int, temperature float64) (string, error) { + body, err := json.Marshal(chatRequest{ + Model: model, + Messages: []chatMessage{ + {Role: "user", Content: prompt}, + }, + MaxTokens: maxTokens, + Temperature: temperature, + }) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + url := c.baseURL + "/v1/chat/completions" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("build request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + if c.apiKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("llm api returned %d: %s", resp.StatusCode, string(respBytes)) + } + + var cr chatResponse + if err := json.Unmarshal(respBytes, &cr); err != nil { + return "", fmt.Errorf("unmarshal response: %w", err) + } + if cr.Error != nil { + return "", fmt.Errorf("llm api error: %s", cr.Error.Message) + } + if len(cr.Choices) == 0 { + return "", fmt.Errorf("llm api returned no choices") + } + + return cr.Choices[0].Message.Content, nil +} diff --git a/cmd/acb-evolver/internal/llm/extract.go b/cmd/acb-evolver/internal/llm/extract.go new file mode 100644 index 0000000..2863c5f --- /dev/null +++ b/cmd/acb-evolver/internal/llm/extract.go @@ -0,0 +1,119 @@ +// Package llm provides an OpenAI-compatible LLM client and utilities for +// extracting bot code from model responses. +package llm + +import ( + "fmt" + "regexp" + "strings" +) + +// ValidLanguages is the set of language identifiers the game engine supports. +var ValidLanguages = map[string]bool{ + "go": true, + "python": true, + "rust": true, + "typescript": true, + "java": true, + "php": true, +} + +// languageAliases maps common LLM-output labels to canonical names. +var languageAliases = map[string]string{ + "golang": "go", + "py": "python", + "rs": "rust", + "ts": "typescript", + "javascript": "typescript", + "js": "typescript", +} + +// fencedBlock matches ```\n\n``` in LLM output. +// The language tag is optional (empty string when absent). +var fencedBlock = regexp.MustCompile("(?s)```([a-zA-Z]*)[ \t]*\n(.*?)```") + +// Candidate holds a single piece of extracted bot code from an LLM response. +type Candidate struct { + Code string + Language string +} + +// ExtractCandidates parses all fenced code blocks from text and returns them +// as Candidates. +// +// If targetLang is non-empty only blocks whose language tag resolves to +// targetLang (after alias expansion) are returned. Language matching is +// case-insensitive. +// +// Returns an error when no matching blocks are found. +func ExtractCandidates(text, targetLang string) ([]Candidate, error) { + matches := fencedBlock.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return nil, fmt.Errorf("no fenced code blocks found in LLM response") + } + + target := strings.ToLower(strings.TrimSpace(targetLang)) + + var out []Candidate + for _, m := range matches { + rawLang := strings.ToLower(strings.TrimSpace(m[1])) + code := strings.TrimSpace(m[2]) + if code == "" { + continue + } + + // Resolve alias to canonical name. + lang := rawLang + if alias, ok := languageAliases[rawLang]; ok { + lang = alias + } + + // Filter by target language when one is specified. + if target != "" && lang != target { + continue + } + + // Skip unlabelled blocks that look like prose, not code. + if lang == "" && !looksLikeCode(code) { + continue + } + + out = append(out, Candidate{Code: code, Language: lang}) + } + + if len(out) == 0 { + if target != "" { + return nil, fmt.Errorf("no %q code blocks found in LLM response", target) + } + return nil, fmt.Errorf("no usable code blocks found in LLM response") + } + return out, nil +} + +// ExtractBestCandidate returns the longest code block matching targetLang. +// It is a convenience wrapper around ExtractCandidates. +func ExtractBestCandidate(text, targetLang string) (*Candidate, error) { + candidates, err := ExtractCandidates(text, targetLang) + if err != nil { + return nil, err + } + best := &candidates[0] + for i := 1; i < len(candidates); i++ { + if len(candidates[i].Code) > len(best.Code) { + best = &candidates[i] + } + } + return best, nil +} + +// looksLikeCode returns true when s contains at least one character that +// commonly appears in source code but rarely in plain prose. +func looksLikeCode(s string) bool { + for _, ch := range s { + switch ch { + case '{', '}', '(', ')', ';', '=', '<', '>', '/', '*': + return true + } + } + return false +} diff --git a/cmd/acb-evolver/internal/llm/extract_test.go b/cmd/acb-evolver/internal/llm/extract_test.go new file mode 100644 index 0000000..2fc2641 --- /dev/null +++ b/cmd/acb-evolver/internal/llm/extract_test.go @@ -0,0 +1,155 @@ +package llm + +import ( + "strings" + "testing" +) + +func TestExtractCandidates_singleBlock(t *testing.T) { + text := "Here is the code:\n```go\npackage main\nfunc main() {}\n```\nDone." + candidates, err := ExtractCandidates(text, "go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("expected 1 candidate, got %d", len(candidates)) + } + if candidates[0].Language != "go" { + t.Errorf("expected language=go, got %q", candidates[0].Language) + } + if !strings.Contains(candidates[0].Code, "func main()") { + t.Errorf("expected code to contain func main(), got %q", candidates[0].Code) + } +} + +func TestExtractCandidates_noBlocks(t *testing.T) { + _, err := ExtractCandidates("just plain text, no code blocks here", "go") + if err == nil { + t.Fatal("expected error for text with no code blocks") + } +} + +func TestExtractCandidates_wrongLanguage(t *testing.T) { + text := "```python\nprint('hello')\n```" + _, err := ExtractCandidates(text, "go") + if err == nil { + t.Fatal("expected error when no blocks match target language") + } +} + +func TestExtractCandidates_languageAlias_golang(t *testing.T) { + text := "```golang\npackage main\nfunc main() {}\n```" + candidates, err := ExtractCandidates(text, "go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 || candidates[0].Language != "go" { + t.Errorf("expected 1 go candidate, got %+v", candidates) + } +} + +func TestExtractCandidates_languageAlias_ts(t *testing.T) { + text := "```ts\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 1 typescript candidate, got %+v", candidates) + } +} + +func TestExtractCandidates_languageAlias_py(t *testing.T) { + text := "```py\nx = 1\n```" + candidates, err := ExtractCandidates(text, "python") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("expected 1 candidate, got %d", len(candidates)) + } +} + +func TestExtractCandidates_multipleBlocks_noFilter(t *testing.T) { + text := "```go\npackage main\n```\n```python\nprint('hi')\n```" + candidates, err := ExtractCandidates(text, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 2 { + t.Fatalf("expected 2 candidates, got %d", len(candidates)) + } +} + +func TestExtractCandidates_multipleBlocks_filterByLang(t *testing.T) { + text := "```go\npackage main\n```\n```python\nprint('hi')\n```\n```go\npackage other\n```" + candidates, err := ExtractCandidates(text, "go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 2 { + t.Fatalf("expected 2 go candidates, got %d", len(candidates)) + } + for _, c := range candidates { + if c.Language != "go" { + t.Errorf("expected language=go, got %q", c.Language) + } + } +} + +func TestExtractBestCandidate_picksLongest(t *testing.T) { + short := "package main\nfunc a() {}" + long := "package main\n\nfunc a() {}\nfunc b() {}\nfunc c() {}\n// extra content here" + text := "```go\n" + short + "\n```\n```go\n" + long + "\n```" + + best, err := ExtractBestCandidate(text, "go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if best.Code != long { + t.Errorf("expected longest code block, got %q", best.Code) + } +} + +func TestExtractBestCandidate_singleBlock(t *testing.T) { + text := "```rust\nfn main() {}\n```" + best, err := ExtractBestCandidate(text, "rust") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if best.Language != "rust" { + t.Errorf("expected language=rust, got %q", best.Language) + } +} + +func TestExtractCandidates_caseInsensitiveTag(t *testing.T) { + text := "```Go\npackage main\nfunc main() {}\n```" + candidates, err := ExtractCandidates(text, "go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("expected 1 candidate, got %d", len(candidates)) + } +} + +func TestExtractCandidates_emptyCodeBlock_skipped(t *testing.T) { + text := "```go\n\n```\n```go\npackage main\nfunc main(){}\n```" + candidates, err := ExtractCandidates(text, "go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // The empty block should be skipped. + if len(candidates) != 1 { + t.Fatalf("expected 1 non-empty candidate, got %d", len(candidates)) + } +} + +func TestLooksLikeCode(t *testing.T) { + if !looksLikeCode("func main() {}") { + t.Error("expected func main() {} to look like code") + } + if looksLikeCode("this is plain prose without any code chars") { + t.Error("expected plain prose not to look like code") + } +} diff --git a/cmd/acb-evolver/internal/prompt/builder.go b/cmd/acb-evolver/internal/prompt/builder.go new file mode 100644 index 0000000..2316270 --- /dev/null +++ b/cmd/acb-evolver/internal/prompt/builder.go @@ -0,0 +1,253 @@ +// Package prompt assembles evolution prompts for the LLM ensemble. +// +// A prompt is built from three sources: +// - Parent programs: high-fitness individuals sampled from the island +// population (typically via tournament selection). +// - Replay analysis: key moments, strategies, and weaknesses extracted +// from recent match replays. +// - Meta description: a snapshot of the current leaderboard and dominant +// strategies, giving the LLM competitive context. +package prompt + +import ( + "fmt" + "strings" + + evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db" +) + +// MatchSummary captures the salient facts from a completed match replay. +type MatchSummary struct { + // MatchID is the unique identifier of the match in the database. + MatchID string + // WinnerName is the name of the winning bot (empty for draws). + WinnerName string + // LoserName is the name of the losing bot (empty for draws). + LoserName string + // Condition is one of "elimination", "dominance", "turns", or "draw". + Condition string + // TurnCount is the number of turns played. + TurnCount int + // Scores holds the final score for each player slot. + Scores []int + // KeyMoments are natural-language sentences describing notable events. + KeyMoments []string + // Strategies lists the key tactics observed in the winning side. + Strategies []string + // Weaknesses lists exploitable patterns observed in the losing side. + Weaknesses []string +} + +// BotSummary is a brief leaderboard entry. +type BotSummary struct { + Name string + Rating float64 + Island string + Evolved bool +} + +// IslandStat summarises a single island's population. +type IslandStat struct { + Count int + AvgFitness float64 + TopFitness float64 +} + +// MetaDescription captures the current state of the competitive meta. +type MetaDescription struct { + // TotalBots is the number of registered bots. + TotalBots int + // TopBots lists the highest-rated bots in descending order. + TopBots []BotSummary + // DominantStrategy is a narrative description of the current meta. + DominantStrategy string + // IslandStats summarises each island's population and fitness. + IslandStats map[string]IslandStat +} + +// Request bundles everything the prompt builder needs to produce a prompt. +type Request struct { + // Parents are the programs selected as evolutionary parents. + Parents []*evolverdb.Program + // Replays is the recent match history used for strategy analysis. + Replays []MatchSummary + // Meta describes the current competitive landscape. + Meta MetaDescription + // Island is the island this candidate will compete on. + Island string + // TargetLang is the programming language for the evolved bot + // (e.g. "go", "python", "rust", "typescript", "java", "php"). + TargetLang string + // Generation is the current evolution generation number. + Generation int +} + +// Assemble builds the full LLM prompt from a Request. +// The returned string is ready to be sent as the user message to the LLM. +func Assemble(r Request) string { + var sb strings.Builder + + writeSystemContext(&sb, r.TargetLang) + writeIslandContext(&sb, r.Island, r.Generation) + writeMetaSection(&sb, r.Meta) + writeReplaySection(&sb, r.Replays) + writeParentSection(&sb, r.Parents) + writeTaskSection(&sb, r.TargetLang) + + return sb.String() +} + +func writeSystemContext(sb *strings.Builder, targetLang string) { + sb.WriteString("You are an AI bot evolution engine for a competitive grid strategy game.\n") + sb.WriteString("Your task is to write an improved bot strategy in ") + sb.WriteString(langDisplayName(targetLang)) + sb.WriteString(" based on the parents and match analysis provided.\n\n") + + sb.WriteString("## Game Rules\n") + sb.WriteString("- Grid: 60×60 toroidal grid with walls, energy pickups, and player cores\n") + sb.WriteString("- Bots spawn from your core (costs 3 energy). Spawn whenever energy ≥ 3.\n") + sb.WriteString("- Each turn: move each bot one step (N/E/S/W) or stay. Submit all moves as JSON.\n") + sb.WriteString("- Collect energy tiles to gain energy. Attack enemy bots by moving onto them.\n") + sb.WriteString("- Win by: eliminating all enemy bots, controlling >50% score, or having the most score when turns run out (max 500).\n") + sb.WriteString("- Vision radius²=49 (~7 tiles). Attack by moving into an enemy.\n\n") +} + +func writeIslandContext(sb *strings.Builder, island string, generation int) { + sb.WriteString("## Island Context\n") + fmt.Fprintf(sb, "Evolving on island **%s** (generation %d).\n", island, generation) + switch island { + case evolverdb.IslandAlpha: + sb.WriteString("Island Alpha favors aggressive, core-rushing strategies.\n") + case evolverdb.IslandBeta: + sb.WriteString("Island Beta favors energy-focused, economic strategies.\n") + case evolverdb.IslandGamma: + sb.WriteString("Island Gamma favors defensive, adaptive strategies.\n") + case evolverdb.IslandDelta: + sb.WriteString("Island Delta is experimental — any novel strategy is welcome.\n") + } + sb.WriteString("\n") +} + +func writeMetaSection(sb *strings.Builder, meta MetaDescription) { + if meta.TotalBots == 0 && len(meta.TopBots) == 0 { + return + } + sb.WriteString("## Current Meta\n") + if meta.TotalBots > 0 { + fmt.Fprintf(sb, "Total active bots: %d\n", meta.TotalBots) + } + if meta.DominantStrategy != "" { + fmt.Fprintf(sb, "Dominant strategy: %s\n", meta.DominantStrategy) + } + if len(meta.TopBots) > 0 { + sb.WriteString("\nTop-rated bots:\n") + for i, bot := range meta.TopBots { + line := fmt.Sprintf(" %d. %s (rating %.0f", i+1, bot.Name, bot.Rating) + if bot.Island != "" { + line += fmt.Sprintf(", island: %s", bot.Island) + } + if bot.Evolved { + line += ", evolved" + } + line += ")\n" + sb.WriteString(line) + } + } + if len(meta.IslandStats) > 0 { + sb.WriteString("\nIsland population stats:\n") + for _, island := range evolverdb.AllIslands { + if stat, ok := meta.IslandStats[island]; ok { + fmt.Fprintf(sb, " %s: %d programs, avg fitness %.3f, top fitness %.3f\n", + island, stat.Count, stat.AvgFitness, stat.TopFitness) + } + } + } + sb.WriteString("\n") +} + +func writeReplaySection(sb *strings.Builder, replays []MatchSummary) { + if len(replays) == 0 { + return + } + sb.WriteString("## Recent Match Analysis\n") + for i, m := range replays { + fmt.Fprintf(sb, "\n### Match %d (ID: %s)\n", i+1, m.MatchID) + if m.Condition == "draw" || m.WinnerName == "" { + fmt.Fprintf(sb, "Result: Draw (%d turns)\n", m.TurnCount) + } else { + fmt.Fprintf(sb, "Result: %s defeated %s (%s, %d turns)\n", + m.WinnerName, m.LoserName, m.Condition, m.TurnCount) + } + if len(m.Scores) > 0 { + fmt.Fprintf(sb, "Scores: %v\n", m.Scores) + } + if len(m.Strategies) > 0 { + fmt.Fprintf(sb, "Winning strategies: %s\n", strings.Join(m.Strategies, ", ")) + } + if len(m.Weaknesses) > 0 { + fmt.Fprintf(sb, "Exploited weaknesses: %s\n", strings.Join(m.Weaknesses, ", ")) + } + if len(m.KeyMoments) > 0 { + sb.WriteString("Key moments:\n") + for _, moment := range m.KeyMoments { + sb.WriteString(" - " + moment + "\n") + } + } + } + sb.WriteString("\n") +} + +func writeParentSection(sb *strings.Builder, parents []*evolverdb.Program) { + if len(parents) == 0 { + return + } + sb.WriteString("## Parent Programs\n") + sb.WriteString("Study these parents and improve upon them:\n\n") + for i, p := range parents { + fmt.Fprintf(sb, "### Parent %d (ID: %d, fitness: %.3f, language: %s)\n", + i+1, p.ID, p.Fitness, p.Language) + if len(p.BehaviorVector) >= 2 { + fmt.Fprintf(sb, "Behavior: aggression=%.2f economy=%.2f\n", + p.BehaviorVector[0], p.BehaviorVector[1]) + } + sb.WriteString("\n```" + p.Language + "\n") + sb.WriteString(p.Code) + if !strings.HasSuffix(p.Code, "\n") { + sb.WriteByte('\n') + } + sb.WriteString("```\n\n") + } +} + +func writeTaskSection(sb *strings.Builder, targetLang string) { + sb.WriteString("## Task\n") + fmt.Fprintf(sb, "Write an **improved** bot strategy in **%s** that:\n", langDisplayName(targetLang)) + sb.WriteString("1. Addresses the weaknesses and counter-strategies identified in the match analysis.\n") + sb.WriteString("2. Builds on the best tactical patterns from the parent programs.\n") + sb.WriteString("3. Introduces at least one novel tactical improvement not present in the parents.\n") + sb.WriteString("4. Is complete and self-contained (define all required game types inline).\n\n") + sb.WriteString("Return **only** the complete bot code in a single fenced code block with no additional explanation:\n") + sb.WriteString("```" + targetLang + "\n") + sb.WriteString("// your complete bot code here\n") + sb.WriteString("```\n") +} + +// langDisplayName returns a human-readable name for a language identifier. +func langDisplayName(lang string) string { + switch lang { + case "go": + return "Go" + case "python": + return "Python" + case "rust": + return "Rust" + case "typescript": + return "TypeScript" + case "java": + return "Java" + case "php": + return "PHP" + default: + return lang + } +} diff --git a/cmd/acb-evolver/internal/prompt/builder_test.go b/cmd/acb-evolver/internal/prompt/builder_test.go new file mode 100644 index 0000000..8c80114 --- /dev/null +++ b/cmd/acb-evolver/internal/prompt/builder_test.go @@ -0,0 +1,172 @@ +package prompt + +import ( + "strings" + "testing" + + evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db" +) + +func TestAssemble_containsGameRules(t *testing.T) { + r := Request{ + Island: evolverdb.IslandAlpha, + TargetLang: "go", + Generation: 1, + } + got := Assemble(r) + for _, want := range []string{"60×60", "energy", "spawn", "toroidal"} { + if !strings.Contains(got, want) { + t.Errorf("expected prompt to contain %q", want) + } + } +} + +func TestAssemble_islandContext(t *testing.T) { + tests := []struct { + island string + keyword string + }{ + {evolverdb.IslandAlpha, "aggressive"}, + {evolverdb.IslandBeta, "energy-focused"}, + {evolverdb.IslandGamma, "defensive"}, + {evolverdb.IslandDelta, "experimental"}, + } + for _, tc := range tests { + r := Request{Island: tc.island, TargetLang: "go", Generation: 2} + got := Assemble(r) + if !strings.Contains(got, tc.keyword) { + t.Errorf("island %s: expected %q in prompt", tc.island, tc.keyword) + } + } +} + +func TestAssemble_targetLanguageAppears(t *testing.T) { + for _, lang := range []string{"go", "python", "rust", "typescript", "java", "php"} { + r := Request{Island: evolverdb.IslandDelta, TargetLang: lang, Generation: 0} + got := Assemble(r) + if !strings.Contains(got, "```"+lang) { + t.Errorf("lang %s: expected fenced block in prompt", lang) + } + } +} + +func TestAssemble_parentCodeEmbedded(t *testing.T) { + parents := []*evolverdb.Program{ + { + ID: 42, + Code: "func main() { /* gatherer */ }", + Language: "go", + Fitness: 0.75, + BehaviorVector: []float64{0.1, 0.9}, + }, + } + r := Request{ + Parents: parents, + Island: evolverdb.IslandBeta, + TargetLang: "go", + Generation: 3, + } + got := Assemble(r) + if !strings.Contains(got, "func main() { /* gatherer */ }") { + t.Error("expected parent code to be embedded in the prompt") + } + if !strings.Contains(got, "fitness: 0.750") { + t.Error("expected parent fitness to appear in the prompt") + } + if !strings.Contains(got, "aggression=0.10") { + t.Error("expected behavior vector to appear in the prompt") + } +} + +func TestAssemble_replayAnalysis(t *testing.T) { + replays := []MatchSummary{ + { + MatchID: "match-001", + WinnerName: "rusher", + LoserName: "gatherer", + Condition: "elimination", + TurnCount: 123, + Scores: []int{42, 10}, + Strategies: []string{"core rush", "aggressive spawn"}, + Weaknesses: []string{"exposed energy lines", "slow response"}, + KeyMoments: []string{"Turn 50: rusher surrounded gatherer core"}, + }, + } + r := Request{ + Replays: replays, + Island: evolverdb.IslandAlpha, + TargetLang: "rust", + Generation: 1, + } + got := Assemble(r) + if !strings.Contains(got, "match-001") { + t.Error("expected match ID in prompt") + } + if !strings.Contains(got, "rusher defeated gatherer") { + t.Error("expected match result in prompt") + } + if !strings.Contains(got, "core rush") { + t.Error("expected strategies in prompt") + } + if !strings.Contains(got, "Turn 50: rusher surrounded gatherer core") { + t.Error("expected key moment in prompt") + } +} + +func TestAssemble_metaDescription(t *testing.T) { + meta := MetaDescription{ + TotalBots: 12, + DominantStrategy: "energy-focused economy", + TopBots: []BotSummary{ + {Name: "gatherer", Rating: 1600, Island: "beta", Evolved: false}, + {Name: "evo-001", Rating: 1550, Island: "alpha", Evolved: true}, + }, + IslandStats: map[string]IslandStat{ + "alpha": {Count: 3, AvgFitness: 0.5, TopFitness: 0.9}, + }, + } + r := Request{ + Meta: meta, + Island: evolverdb.IslandAlpha, + TargetLang: "go", + Generation: 5, + } + got := Assemble(r) + if !strings.Contains(got, "12") { + t.Error("expected total bot count in prompt") + } + if !strings.Contains(got, "energy-focused economy") { + t.Error("expected dominant strategy in prompt") + } + if !strings.Contains(got, "gatherer") { + t.Error("expected top bot name in prompt") + } + if !strings.Contains(got, "evolved") { + t.Error("expected evolved flag for evo-001 in prompt") + } +} + +func TestAssemble_emptyMeta_noMetaSection(t *testing.T) { + r := Request{ + Island: evolverdb.IslandDelta, + TargetLang: "python", + Generation: 0, + } + got := Assemble(r) + // Meta section heading should not appear when meta is empty. + if strings.Contains(got, "## Current Meta") { + t.Error("expected no meta section when meta is empty") + } +} + +func TestAssemble_generationAppearsInIslandContext(t *testing.T) { + r := Request{ + Island: evolverdb.IslandGamma, + TargetLang: "java", + Generation: 7, + } + got := Assemble(r) + if !strings.Contains(got, "generation 7") { + t.Error("expected generation number in island context") + } +} diff --git a/cmd/acb-evolver/internal/selector/tournament.go b/cmd/acb-evolver/internal/selector/tournament.go new file mode 100644 index 0000000..cd01656 --- /dev/null +++ b/cmd/acb-evolver/internal/selector/tournament.go @@ -0,0 +1,55 @@ +// Package selector implements parent sampling strategies for the evolution +// pipeline. The primary strategy is tournament selection, which balances +// selection pressure (favoring fit individuals) with diversity (random +// sampling prevents premature convergence). +package selector + +import ( + "math/rand" + + evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db" +) + +// TournamentSelect picks the highest-fitness program from k randomly sampled +// candidates drawn without replacement from programs. +// +// When k ≥ len(programs) the function returns the globally best program. +// Returns nil when programs is empty. +func TournamentSelect(programs []*evolverdb.Program, k int, rng *rand.Rand) *evolverdb.Program { + if len(programs) == 0 { + return nil + } + + // If k covers the whole population, just return the global best. + if k >= len(programs) { + best := programs[0] + for _, p := range programs[1:] { + if p.Fitness > best.Fitness { + best = p + } + } + return best + } + + // Sample k distinct indices using a Fisher-Yates partial shuffle. + indices := rng.Perm(len(programs))[:k] + + best := programs[indices[0]] + for _, idx := range indices[1:] { + if programs[idx].Fitness > best.Fitness { + best = programs[idx] + } + } + return best +} + +// SelectParents draws n parents via tournament selection with tournament size k. +// The same program may be selected more than once (sampling with replacement +// across tournaments). +func SelectParents(programs []*evolverdb.Program, n, k int, rng *rand.Rand) []*evolverdb.Program { + parents := make([]*evolverdb.Program, n) + for i := range parents { + parents[i] = TournamentSelect(programs, k, rng) + } + return parents +} diff --git a/cmd/acb-evolver/internal/selector/tournament_test.go b/cmd/acb-evolver/internal/selector/tournament_test.go new file mode 100644 index 0000000..e48313d --- /dev/null +++ b/cmd/acb-evolver/internal/selector/tournament_test.go @@ -0,0 +1,90 @@ +package selector + +import ( + "math/rand" + "testing" + + evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db" +) + +func makePrograms(fitnesses ...float64) []*evolverdb.Program { + out := make([]*evolverdb.Program, len(fitnesses)) + for i, f := range fitnesses { + out[i] = &evolverdb.Program{ID: int64(i + 1), Fitness: f} + } + return out +} + +func TestTournamentSelect_empty(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + got := TournamentSelect(nil, 3, rng) + if got != nil { + t.Fatalf("expected nil for empty population, got %+v", got) + } +} + +func TestTournamentSelect_singleProgram(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + programs := makePrograms(5.0) + got := TournamentSelect(programs, 3, rng) + if got != programs[0] { + t.Fatalf("expected sole program to be returned") + } +} + +func TestTournamentSelect_kLargerThanPopulation(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + programs := makePrograms(1.0, 3.0, 2.0) + // k=10 > len=3, so the global best (fitness=3.0) must be returned. + got := TournamentSelect(programs, 10, rng) + if got.Fitness != 3.0 { + t.Fatalf("expected global best (fitness=3.0), got %.1f", got.Fitness) + } +} + +func TestTournamentSelect_selectsBestAmongSampled(t *testing.T) { + // Use a fixed seed so the test is deterministic. + rng := rand.New(rand.NewSource(1)) + programs := makePrograms(1.0, 5.0, 2.0, 4.0, 3.0) + + // Run many tournaments; the highest-fitness program (5.0) should win + // significantly more often than any other. + wins := make(map[float64]int) + const rounds = 200 + for i := 0; i < rounds; i++ { + p := TournamentSelect(programs, 3, rng) + wins[p.Fitness]++ + } + if wins[5.0] == 0 { + t.Fatalf("best program (fitness=5.0) never won in %d rounds", rounds) + } + // It should win the most. + for f, w := range wins { + if f != 5.0 && w >= wins[5.0] { + t.Errorf("program with fitness=%.1f won %d times, >= best program %d times", f, w, wins[5.0]) + } + } +} + +func TestSelectParents_count(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + programs := makePrograms(1.0, 2.0, 3.0, 4.0, 5.0) + parents := SelectParents(programs, 4, 2, rng) + if len(parents) != 4 { + t.Fatalf("expected 4 parents, got %d", len(parents)) + } + for i, p := range parents { + if p == nil { + t.Errorf("parent[%d] is nil", i) + } + } +} + +func TestSelectParents_nEqualsOne(t *testing.T) { + rng := rand.New(rand.NewSource(99)) + programs := makePrograms(1.0, 2.0, 3.0) + parents := SelectParents(programs, 1, 2, rng) + if len(parents) != 1 { + t.Fatalf("expected 1 parent, got %d", len(parents)) + } +}