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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-26 22:26:09 -04:00
parent be41af831f
commit bd4b0d3244
7 changed files with 1027 additions and 0 deletions

View file

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

View file

@ -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 ```<lang>\n<code>\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
}

View file

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

View file

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

View file

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

View file

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

View file

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