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:
parent
04927a76b0
commit
d43cf83471
4 changed files with 389 additions and 0 deletions
207
cmd/acb-evolver/internal/crosspoll/crosspoll.go
Normal file
207
cmd/acb-evolver/internal/crosspoll/crosspoll.go
Normal 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)
|
||||
}
|
||||
146
cmd/acb-evolver/internal/crosspoll/crosspoll_test.go
Normal file
146
cmd/acb-evolver/internal/crosspoll/crosspoll_test.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue