From d43cf834712ab85597c2cf7e937ca9a06ef88346 Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 15:13:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(evolver):=20island=20cross-pollination=20e?= =?UTF-8?q?very=2050=20generations=20per=20=C2=A710.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds cross-pollination logic that copies the top program from each island to a random other island every 50 generations. When source and target islands use different languages, the LLM translates the code. Generation boundaries are tracked per-island to prevent duplicate events. - New crosspoll package with boundary detection, migration, and LLM translation - Added MaxGenerationByIsland DB query for generation counter tracking - Integrated into RunEvolutionLoop with observability logging - Tests for boundary logic, translation prompts, and target selection Co-Authored-By: Claude Opus 4.7 --- .../internal/crosspoll/crosspoll.go | 207 ++++++++++++++++++ .../internal/crosspoll/crosspoll_test.go | 146 ++++++++++++ cmd/acb-evolver/internal/db/programs.go | 22 ++ cmd/acb-evolver/run.go | 14 ++ 4 files changed, 389 insertions(+) create mode 100644 cmd/acb-evolver/internal/crosspoll/crosspoll.go create mode 100644 cmd/acb-evolver/internal/crosspoll/crosspoll_test.go diff --git a/cmd/acb-evolver/internal/crosspoll/crosspoll.go b/cmd/acb-evolver/internal/crosspoll/crosspoll.go new file mode 100644 index 0000000..4904cb0 --- /dev/null +++ b/cmd/acb-evolver/internal/crosspoll/crosspoll.go @@ -0,0 +1,207 @@ +// Package crosspoll implements island cross-pollination per §10.2 of the plan. +// +// Every 50 generations, the top program from each island is copied to a random +// other island. If the target island uses a different language, the LLM +// translates the code. +package crosspoll + +import ( + "context" + "fmt" + "log" + "math/rand" + + evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db" + "github.com/aicodebattle/acb/cmd/acb-evolver/internal/llm" +) + +const generationInterval = 50 + +// PollinationResult records a single cross-pollination event. +type PollinationResult struct { + SourceIsland string + TargetIsland string + ProgramID int64 + Translated bool + SourceLang string + TargetLang string +} + +// Checker determines which islands need cross-pollination and executes it. +type Checker struct { + store *evolverdb.Store + client *llm.Client + rng *rand.Rand +} + +// NewChecker creates a Checker backed by the given store and LLM client. +func NewChecker(store *evolverdb.Store, client *llm.Client, rng *rand.Rand) *Checker { + return &Checker{store: store, client: client, rng: rng} +} + +// CheckAndPollinate checks all islands and performs cross-pollination for any +// island whose generation is a multiple of 50. Returns the list of pollination +// events that occurred. +// +// prevGens tracks the last-known max generation per island (from the previous +// check). An island triggers pollination only when it crosses a 50-generation +// boundary since the last check, preventing duplicate events. +func (c *Checker) CheckAndPollinate(ctx context.Context, prevGens map[string]int, verbose bool) ([]PollinationResult, error) { + curGens, err := c.store.MaxGenerationByIsland(ctx) + if err != nil { + return nil, fmt.Errorf("query max generations: %w", err) + } + + var results []PollinationResult + + for _, island := range evolverdb.AllIslands { + cur := curGens[island] + prev := prevGens[island] + + // Find the next 50-boundary after prev that cur has reached or passed. + nextBoundary := ((prev / generationInterval) + 1) * generationInterval + for nextBoundary <= cur && nextBoundary > 0 { + if verbose { + log.Printf(" Cross-pollination: island %s hit generation %d", island, nextBoundary) + } + result, err := c.pollinateIsland(ctx, island, verbose) + if err != nil { + log.Printf(" Cross-pollination error for island %s at gen %d: %v", island, nextBoundary, err) + } else { + results = append(results, result) + } + nextBoundary += generationInterval + } + + prevGens[island] = cur + } + + return results, nil +} + +// pollinateIsland copies the top program from sourceIsland to a random other +// island, translating if the languages differ. +func (c *Checker) pollinateIsland(ctx context.Context, sourceIsland string, verbose bool) (PollinationResult, error) { + // Get top program by fitness on the source island. + topProgs, err := c.store.ListTopByIsland(ctx, sourceIsland, 1) + if err != nil { + return PollinationResult{}, fmt.Errorf("list top on %s: %w", sourceIsland, err) + } + if len(topProgs) == 0 { + return PollinationResult{}, fmt.Errorf("no programs on island %s", sourceIsland) + } + top := topProgs[0] + + // Pick a random target island (different from source). + targetIsland := c.pickTargetIsland(sourceIsland) + + // Determine target language from the most-recent program on the target island. + targetLang := top.Language // default: same language + targetProgs, err := c.store.ListTopByIsland(ctx, targetIsland, 1) + if err != nil { + return PollinationResult{}, fmt.Errorf("list top on target %s: %w", targetIsland, err) + } + if len(targetProgs) > 0 { + targetLang = targetProgs[0].Language + } + + translated := false + code := top.Code + + if top.Language != targetLang { + // Translate via LLM + translatedCode, err := c.translate(ctx, top.Code, top.Language, targetLang) + if err != nil { + log.Printf(" Translation %s→%s failed: %v; copying original code", top.Language, targetLang, err) + } else { + code = translatedCode + translated = true + } + } + + // Copy the program to the target island (same generation, new entry). + behaviorVec := top.BehaviorVector + if len(behaviorVec) < 2 { + behaviorVec = []float64{0.5, 0.5} + } + + newID, err := c.store.Create(ctx, &evolverdb.Program{ + Code: code, + Language: targetLang, + Island: targetIsland, + Generation: top.Generation, + ParentIDs: []int64{top.ID}, + BehaviorVector: behaviorVec, + Fitness: top.Fitness * 0.9, // slight fitness penalty for migration + Promoted: false, + }) + if err != nil { + return PollinationResult{}, fmt.Errorf("insert migrated program: %w", err) + } + + result := PollinationResult{ + SourceIsland: sourceIsland, + TargetIsland: targetIsland, + ProgramID: newID, + Translated: translated, + SourceLang: top.Language, + TargetLang: targetLang, + } + + log.Printf(" Cross-pollinated: island %s → %s, program %d → %d, lang %s→%s (translated=%v)", + sourceIsland, targetIsland, top.ID, newID, top.Language, targetLang, translated) + + return result, nil +} + +// pickTargetIsland returns a random island different from source. +func (c *Checker) pickTargetIsland(source string) string { + others := make([]string, 0, len(evolverdb.AllIslands)-1) + for _, island := range evolverdb.AllIslands { + if island != source { + others = append(others, island) + } + } + return others[c.rng.Intn(len(others))] +} + +// translate invokes the LLM to translate bot code from one language to another. +func (c *Checker) translate(ctx context.Context, code, fromLang, toLang string) (string, error) { + prompt := buildTranslationPrompt(code, fromLang, toLang) + + req := llm.GenerateRequest{ + Prompt: prompt, + Tier: llm.TierStrong, // use strong model for accurate translation + TargetLang: toLang, + } + + resp, err := c.client.Generate(ctx, req) + if err != nil { + return "", fmt.Errorf("llm translation: %w", err) + } + if resp.Candidate == nil { + return "", fmt.Errorf("llm returned no candidate") + } + return resp.Candidate.Code, nil +} + +func buildTranslationPrompt(code, fromLang, toLang string) string { + return fmt.Sprintf(`You are translating a competitive bot for AI Code Battle from %s to %s. +The bot is an HTTP server that: +- Listens on port 8080 +- Handles GET /health (returns 200) +- Handles POST /turn with HMAC-SHA256 request verification +- Returns JSON: {"moves": [{"row": N, "col": N, "direction": "N"|"E"|"S"|"W"}]} +- May include optional "debug" field in response + +Translate the following bot preserving the EXACT same strategy and behavior. +Use idiomatic %s patterns and standard library only. +The translated bot must be a complete, self-contained HTTP server. + +Source code in %s: +`+"```"+` +%s +`+"```"+` + +Return ONLY the translated %s code in a single fenced code block:`, fromLang, toLang, toLang, fromLang, code, toLang) +} diff --git a/cmd/acb-evolver/internal/crosspoll/crosspoll_test.go b/cmd/acb-evolver/internal/crosspoll/crosspoll_test.go new file mode 100644 index 0000000..d90bdc4 --- /dev/null +++ b/cmd/acb-evolver/internal/crosspoll/crosspoll_test.go @@ -0,0 +1,146 @@ +package crosspoll + +import ( + "math/rand" + "strings" + "testing" + + evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db" +) + +func TestBuildTranslationPrompt_containsBothLanguages(t *testing.T) { + got := buildTranslationPrompt("func main() {}", "go", "python") + if !strings.Contains(got, "go") { + t.Error("expected source language in prompt") + } + if !strings.Contains(got, "python") { + t.Error("expected target language in prompt") + } + if !strings.Contains(got, "func main() {}") { + t.Error("expected source code in prompt") + } +} + +func TestBuildTranslationPrompt_containsHTTPSpec(t *testing.T) { + got := buildTranslationPrompt("code", "python", "rust") + for _, want := range []string{"port 8080", "GET /health", "POST /turn", "HMAC"} { + if !strings.Contains(got, want) { + t.Errorf("expected %q in translation prompt", want) + } + } +} + +func TestBuildTranslationPrompt_fencedCodeBlock(t *testing.T) { + got := buildTranslationPrompt("print('hi')", "python", "go") + if !strings.Contains(got, "```") { + t.Error("expected fenced code block in prompt") + } +} + +func TestPickTargetIsland_neverSource(t *testing.T) { + rng := newRandZero() + // Not a real Checker, just need the method. + c := &Checker{rng: rng} + + for _, source := range evolverdb.AllIslands { + for i := 0; i < 20; i++ { + target := c.pickTargetIsland(source) + if target == source { + t.Errorf("pickTargetIsland(%q) returned same island", source) + } + } + } +} + +func TestPickTargetIsland_validIsland(t *testing.T) { + c := &Checker{rng: newRandZero()} + valid := map[string]bool{} + for _, island := range evolverdb.AllIslands { + valid[island] = true + } + + for _, source := range evolverdb.AllIslands { + target := c.pickTargetIsland(source) + if !valid[target] { + t.Errorf("pickTargetIsland(%q) returned invalid island %q", source, target) + } + } +} + +func TestCheckAndPollinate_noBoundary_noop(t *testing.T) { + // This test validates the boundary logic without a DB. + // When cur < 50, no pollination should trigger. + // We test the boundary computation logic directly. + + // Generation 49 should not trigger (49 < 50). + nextBoundary := ((0 / generationInterval) + 1) * generationInterval // = 50 + if nextBoundary <= 49 { + t.Error("generation 49 should not trigger pollination") + } + + // Generation 50 should trigger. + if nextBoundary > 50 { + t.Error("generation 50 should trigger pollination") + } +} + +func TestCheckAndPollinate_boundaryExactly50(t *testing.T) { + prev := 0 + cur := 50 + nextBoundary := ((prev / generationInterval) + 1) * generationInterval + if nextBoundary != 50 { + t.Fatalf("expected first boundary at 50, got %d", nextBoundary) + } + if nextBoundary > cur { + t.Error("50 should trigger at gen 50") + } +} + +func TestCheckAndPollination_multipleBoundaries(t *testing.T) { + prev := 49 + cur := 102 + + count := 0 + nextBoundary := ((prev / generationInterval) + 1) * generationInterval // = 50 + for nextBoundary <= cur && nextBoundary > 0 { + count++ + nextBoundary += generationInterval + } + + // Should trigger at 50 and 100 = 2 pollinations. + if count != 2 { + t.Errorf("expected 2 pollinations for prev=49 cur=102, got %d", count) + } +} + +func TestCheckAndPollination_noDuplicateOnRecheck(t *testing.T) { + prev := 50 + cur := 50 + + nextBoundary := ((prev / generationInterval) + 1) * generationInterval // = 100 + if nextBoundary <= cur { + t.Error("should not re-trigger at gen 50 when prev=50") + } +} + +func TestCheckAndPollination_skipsFrom0to100(t *testing.T) { + prev := 0 + cur := 100 + + count := 0 + nextBoundary := ((prev / generationInterval) + 1) * generationInterval // = 50 + for nextBoundary <= cur && nextBoundary > 0 { + count++ + nextBoundary += generationInterval + } + + // Should trigger at 50 and 100 = 2 pollinations. + if count != 2 { + t.Errorf("expected 2 pollinations for prev=0 cur=100, got %d", count) + } +} + +// newRandZero creates a deterministic RNG for reproducible tests. +func newRandZero() *rand.Rand { + return rand.New(rand.NewSource(0)) +} diff --git a/cmd/acb-evolver/internal/db/programs.go b/cmd/acb-evolver/internal/db/programs.go index 43f3f6c..208f3b3 100644 --- a/cmd/acb-evolver/internal/db/programs.go +++ b/cmd/acb-evolver/internal/db/programs.go @@ -352,6 +352,28 @@ func (s *Store) ListTopByIsland(ctx context.Context, island string, limit int) ( return programs, rows.Err() } +// MaxGenerationByIsland returns the maximum generation number for each island. +// Islands with no programs are omitted from the map. +func (s *Store) MaxGenerationByIsland(ctx context.Context) (map[string]int, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT island, COALESCE(MAX(generation), 0) FROM programs GROUP BY island`) + if err != nil { + return nil, fmt.Errorf("max generation by island: %w", err) + } + defer rows.Close() + + result := make(map[string]int) + for rows.Next() { + var island string + var maxGen int + if err := rows.Scan(&island, &maxGen); err != nil { + return nil, fmt.Errorf("scan max generation: %w", err) + } + result[island] = maxGen + } + return result, rows.Err() +} + // GetLineage returns all ancestor program IDs for a given program by // traversing the parent_ids chain recursively. func (s *Store) GetLineage(ctx context.Context, id int64) ([]int64, error) { diff --git a/cmd/acb-evolver/run.go b/cmd/acb-evolver/run.go index f7c3b90..dfbe914 100644 --- a/cmd/acb-evolver/run.go +++ b/cmd/acb-evolver/run.go @@ -33,6 +33,7 @@ import ( evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db" "github.com/aicodebattle/acb/cmd/acb-evolver/internal/arena" + "github.com/aicodebattle/acb/cmd/acb-evolver/internal/crosspoll" "github.com/aicodebattle/acb/cmd/acb-evolver/internal/live" "github.com/aicodebattle/acb/cmd/acb-evolver/internal/llm" "github.com/aicodebattle/acb/cmd/acb-evolver/internal/mapelites" @@ -111,6 +112,7 @@ type RunStats struct { Evaluated int Promoted int Retired int + CrossPollinated int Errors int StartTime time.Time } @@ -156,6 +158,9 @@ func RunEvolutionLoop(ctx context.Context, dbURL string, args []string) { // Track last evolution time per island for cooldown lastEvolved := make(map[string]time.Time) + // Track per-island generation counters for cross-pollination boundary detection + prevGens := make(map[string]int) + // Stats stats := RunStats{StartTime: time.Now()} @@ -226,6 +231,14 @@ func RunEvolutionLoop(ctx context.Context, dbURL string, args []string) { // Export live.json after each cycle exportLive(ctx, db, cfg, *verbose) + // Check for cross-pollination (§10.2: every 50 generations per island) + cpChecker := crosspoll.NewChecker(store, llm.NewClient(cfg.LLMURL, ""), rng) + cpResults, err := cpChecker.CheckAndPollinate(ctx, prevGens, *verbose) + if err != nil { + log.Printf("Cross-pollination check error: %v", err) + } + stats.CrossPollinated += len(cpResults) + // Continuous mode: wait for next cycle if *continuous { lastEvolved[island] = time.Now() @@ -672,6 +685,7 @@ func printStats(stats *RunStats) { log.Printf(" Evaluated: %d", stats.Evaluated) log.Printf(" Promoted: %d", stats.Promoted) log.Printf(" Retired: %d", stats.Retired) + log.Printf(" Cross-pollinated: %d", stats.CrossPollinated) log.Printf(" Errors: %d", stats.Errors) log.Printf(" Uptime: %v", elapsed.Round(time.Second)) }