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:
parent
be41af831f
commit
bd4b0d3244
7 changed files with 1027 additions and 0 deletions
183
cmd/acb-evolver/internal/llm/client.go
Normal file
183
cmd/acb-evolver/internal/llm/client.go
Normal 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
|
||||
}
|
||||
119
cmd/acb-evolver/internal/llm/extract.go
Normal file
119
cmd/acb-evolver/internal/llm/extract.go
Normal 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
|
||||
}
|
||||
155
cmd/acb-evolver/internal/llm/extract_test.go
Normal file
155
cmd/acb-evolver/internal/llm/extract_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
253
cmd/acb-evolver/internal/prompt/builder.go
Normal file
253
cmd/acb-evolver/internal/prompt/builder.go
Normal 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
|
||||
}
|
||||
}
|
||||
172
cmd/acb-evolver/internal/prompt/builder_test.go
Normal file
172
cmd/acb-evolver/internal/prompt/builder_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
55
cmd/acb-evolver/internal/selector/tournament.go
Normal file
55
cmd/acb-evolver/internal/selector/tournament.go
Normal 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
|
||||
}
|
||||
90
cmd/acb-evolver/internal/selector/tournament_test.go
Normal file
90
cmd/acb-evolver/internal/selector/tournament_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue