feat(evolver): island cross-pollination every 50 generations per §10.2

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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 15:13:27 -04:00
parent 04927a76b0
commit d43cf83471
4 changed files with 389 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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