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