test(evolver): integration tests for cross-pollination logic per §10.2
Adds mock store/LLM implementations and tests for CheckAndPollinate: generation boundaries, fitness penalties, translation triggers, multi-boundary catch-up, and empty island handling. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c56cc8bae6
commit
e90d2e37c9
1 changed files with 376 additions and 14 deletions
|
|
@ -1,13 +1,93 @@
|
|||
package crosspoll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db"
|
||||
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/llm"
|
||||
)
|
||||
|
||||
// ── Mock implementations ──────────────────────────────────────────────────
|
||||
|
||||
// mockStore implements programStore for testing.
|
||||
type mockStore struct {
|
||||
mu sync.Mutex
|
||||
programs []*evolverdb.Program
|
||||
nextID int64
|
||||
createdCalls [] *evolverdb.Program // captures programs passed to Create
|
||||
}
|
||||
|
||||
func newMockStore(programs ...*evolverdb.Program) *mockStore {
|
||||
return &mockStore{
|
||||
programs: programs,
|
||||
nextID: int64(len(programs)) + 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockStore) MaxGenerationByIsland(ctx context.Context) (map[string]int, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
result := make(map[string]int)
|
||||
for _, p := range m.programs {
|
||||
if p.Generation > result[p.Island] {
|
||||
result[p.Island] = p.Generation
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockStore) ListTopByIsland(ctx context.Context, island string, limit int) ([]*evolverdb.Program, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Filter by island, already ordered by fitness desc in test data.
|
||||
var filtered []*evolverdb.Program
|
||||
for _, p := range m.programs {
|
||||
if p.Island == island {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
if limit > len(filtered) {
|
||||
limit = len(filtered)
|
||||
}
|
||||
return filtered[:limit], nil
|
||||
}
|
||||
|
||||
func (m *mockStore) Create(ctx context.Context, p *evolverdb.Program) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.nextID++
|
||||
p.ID = m.nextID
|
||||
m.programs = append(m.programs, p)
|
||||
m.createdCalls = append(m.createdCalls, p)
|
||||
return m.nextID, nil
|
||||
}
|
||||
|
||||
// mockLLM implements llmGenerator for testing.
|
||||
type mockLLM struct {
|
||||
generateCalled bool
|
||||
generateFunc func(ctx context.Context, req llm.GenerateRequest) (*llm.GenerateResponse, error)
|
||||
}
|
||||
|
||||
func (m *mockLLM) Generate(ctx context.Context, req llm.GenerateRequest) (*llm.GenerateResponse, error) {
|
||||
m.generateCalled = true
|
||||
if m.generateFunc != nil {
|
||||
return m.generateFunc(ctx, req)
|
||||
}
|
||||
return &llm.GenerateResponse{
|
||||
Candidate: &llm.Candidate{Code: "translated_code"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Existing tests ────────────────────────────────────────────────────────
|
||||
|
||||
func TestBuildTranslationPrompt_containsBothLanguages(t *testing.T) {
|
||||
got := buildTranslationPrompt("func main() {}", "go", "python")
|
||||
if !strings.Contains(got, "go") {
|
||||
|
|
@ -39,7 +119,6 @@ func TestBuildTranslationPrompt_fencedCodeBlock(t *testing.T) {
|
|||
|
||||
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 {
|
||||
|
|
@ -68,17 +147,10 @@ func TestPickTargetIsland_validIsland(t *testing.T) {
|
|||
}
|
||||
|
||||
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
|
||||
nextBoundary := ((0 / generationInterval) + 1) * generationInterval
|
||||
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")
|
||||
}
|
||||
|
|
@ -101,13 +173,12 @@ func TestCheckAndPollination_multipleBoundaries(t *testing.T) {
|
|||
cur := 102
|
||||
|
||||
count := 0
|
||||
nextBoundary := ((prev / generationInterval) + 1) * generationInterval // = 50
|
||||
nextBoundary := ((prev / generationInterval) + 1) * generationInterval
|
||||
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)
|
||||
}
|
||||
|
|
@ -117,7 +188,7 @@ func TestCheckAndPollination_noDuplicateOnRecheck(t *testing.T) {
|
|||
prev := 50
|
||||
cur := 50
|
||||
|
||||
nextBoundary := ((prev / generationInterval) + 1) * generationInterval // = 100
|
||||
nextBoundary := ((prev / generationInterval) + 1) * generationInterval
|
||||
if nextBoundary <= cur {
|
||||
t.Error("should not re-trigger at gen 50 when prev=50")
|
||||
}
|
||||
|
|
@ -128,18 +199,309 @@ func TestCheckAndPollination_skipsFrom0to100(t *testing.T) {
|
|||
cur := 100
|
||||
|
||||
count := 0
|
||||
nextBoundary := ((prev / generationInterval) + 1) * generationInterval // = 50
|
||||
nextBoundary := ((prev / generationInterval) + 1) * generationInterval
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integration tests with mock store ─────────────────────────────────────
|
||||
|
||||
func seedIsland(store *mockStore, island, lang string, fitness float64, generation int) {
|
||||
store.programs = append(store.programs, &evolverdb.Program{
|
||||
ID: store.nextID,
|
||||
Code: fmt.Sprintf("code_%s_gen%d", island, generation),
|
||||
Language: lang,
|
||||
Island: island,
|
||||
Generation: generation,
|
||||
ParentIDs: []int64{},
|
||||
BehaviorVector: []float64{0.5, 0.5},
|
||||
Fitness: fitness,
|
||||
})
|
||||
store.nextID++
|
||||
}
|
||||
|
||||
func TestCheckAndPollinate_fiftyGenerations_oneEventPerIsland(t *testing.T) {
|
||||
// Seed all 4 islands at generation 50 with top programs.
|
||||
store := newMockStore()
|
||||
for _, island := range evolverdb.AllIslands {
|
||||
seedIsland(store, island, "go", 100.0, 50)
|
||||
// Add a lower-fitness program so ListTopByIsland returns the right one
|
||||
seedIsland(store, island, "go", 50.0, 30)
|
||||
}
|
||||
|
||||
llmClient := &mockLLM{}
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
checker := &Checker{store: store, client: llmClient, rng: rng}
|
||||
|
||||
prevGens := make(map[string]int) // all start at 0
|
||||
results, err := checker.CheckAndPollinate(context.Background(), prevGens, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckAndPollinate: %v", err)
|
||||
}
|
||||
|
||||
// Expect exactly 4 events — one per island.
|
||||
if len(results) != 4 {
|
||||
t.Fatalf("expected 4 pollination events (one per island), got %d", len(results))
|
||||
}
|
||||
|
||||
// Verify each island triggered exactly once.
|
||||
triggered := make(map[string]int)
|
||||
for _, r := range results {
|
||||
triggered[r.SourceIsland]++
|
||||
}
|
||||
for _, island := range evolverdb.AllIslands {
|
||||
if triggered[island] != 1 {
|
||||
t.Errorf("island %s: expected 1 event, got %d", island, triggered[island])
|
||||
}
|
||||
}
|
||||
|
||||
// Verify no translation needed (all same language).
|
||||
for _, r := range results {
|
||||
if r.Translated {
|
||||
t.Errorf("event from %s→%s should not have translation (same lang)", r.SourceIsland, r.TargetIsland)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify prevGens was updated.
|
||||
for _, island := range evolverdb.AllIslands {
|
||||
if prevGens[island] != 50 {
|
||||
t.Errorf("prevGens[%s] = %d, want 50", island, prevGens[island])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAndPollinate_underFifty_noEvents(t *testing.T) {
|
||||
store := newMockStore()
|
||||
for _, island := range evolverdb.AllIslands {
|
||||
seedIsland(store, island, "go", 100.0, 49)
|
||||
}
|
||||
|
||||
llmClient := &mockLLM{}
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
checker := &Checker{store: store, client: llmClient, rng: rng}
|
||||
|
||||
prevGens := make(map[string]int)
|
||||
results, err := checker.CheckAndPollinate(context.Background(), prevGens, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckAndPollinate: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("expected 0 events for gen 49, got %d", len(results))
|
||||
}
|
||||
|
||||
if llmClient.generateCalled {
|
||||
t.Error("LLM should not have been called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAndPollinate_copiedProgramAttributes(t *testing.T) {
|
||||
// Only alpha at gen 50 — other islands below boundary.
|
||||
store := newMockStore()
|
||||
seedIsland(store, evolverdb.IslandAlpha, "go", 100.0, 50)
|
||||
seedIsland(store, evolverdb.IslandBeta, "go", 80.0, 10)
|
||||
seedIsland(store, evolverdb.IslandGamma, "go", 70.0, 10)
|
||||
seedIsland(store, evolverdb.IslandDelta, "go", 60.0, 10)
|
||||
|
||||
llmClient := &mockLLM{}
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
checker := &Checker{store: store, client: llmClient, rng: rng}
|
||||
|
||||
prevGens := make(map[string]int)
|
||||
results, err := checker.CheckAndPollinate(context.Background(), prevGens, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckAndPollinate: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 event (alpha only at gen 50), got %d", len(results))
|
||||
}
|
||||
|
||||
r := results[0]
|
||||
if r.SourceIsland != evolverdb.IslandAlpha {
|
||||
t.Errorf("source island: got %q, want %q", r.SourceIsland, evolverdb.IslandAlpha)
|
||||
}
|
||||
if r.SourceIsland == r.TargetIsland {
|
||||
t.Error("target island should differ from source")
|
||||
}
|
||||
|
||||
// Verify the created program has the fitness penalty (0.9x).
|
||||
if len(store.createdCalls) != 1 {
|
||||
t.Fatalf("expected 1 Create call, got %d", len(store.createdCalls))
|
||||
}
|
||||
created := store.createdCalls[0]
|
||||
if created.Fitness != 90.0 {
|
||||
t.Errorf("migrated program fitness: got %f, want 90.0 (0.9*100)", created.Fitness)
|
||||
}
|
||||
if created.Island != r.TargetIsland {
|
||||
t.Errorf("migrated program island: got %q, want %q", created.Island, r.TargetIsland)
|
||||
}
|
||||
if len(created.ParentIDs) != 1 {
|
||||
t.Fatalf("migrated program should have 1 parent, got %d", len(created.ParentIDs))
|
||||
}
|
||||
// Parent should be the top program on alpha (ID=1).
|
||||
if created.ParentIDs[0] != 1 {
|
||||
t.Errorf("parent ID: got %d, want 1 (top alpha program)", created.ParentIDs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAndPollinate_translationTriggered_differentLanguages(t *testing.T) {
|
||||
// Alpha speaks Go; all other islands speak Python so any target needs translation.
|
||||
store := newMockStore()
|
||||
seedIsland(store, evolverdb.IslandAlpha, "go", 100.0, 50)
|
||||
seedIsland(store, evolverdb.IslandBeta, "python", 80.0, 10)
|
||||
seedIsland(store, evolverdb.IslandGamma, "python", 70.0, 10)
|
||||
seedIsland(store, evolverdb.IslandDelta, "python", 60.0, 10)
|
||||
|
||||
translatedCode := "def handle_turn(): pass"
|
||||
llmClient := &mockLLM{
|
||||
generateFunc: func(ctx context.Context, req llm.GenerateRequest) (*llm.GenerateResponse, error) {
|
||||
return &llm.GenerateResponse{
|
||||
Candidate: &llm.Candidate{Code: translatedCode},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
checker := &Checker{store: store, client: llmClient, rng: rng}
|
||||
|
||||
prevGens := make(map[string]int)
|
||||
results, err := checker.CheckAndPollinate(context.Background(), prevGens, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckAndPollinate: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(results))
|
||||
}
|
||||
|
||||
r := results[0]
|
||||
if !r.Translated {
|
||||
t.Error("expected translation for go→python")
|
||||
}
|
||||
if !llmClient.generateCalled {
|
||||
t.Error("expected LLM to be called for translation")
|
||||
}
|
||||
if r.SourceLang != "go" {
|
||||
t.Errorf("source lang: got %q, want go", r.SourceLang)
|
||||
}
|
||||
if r.TargetLang != "python" {
|
||||
t.Errorf("target lang: got %q, want python", r.TargetLang)
|
||||
}
|
||||
|
||||
// Verify the translated code was stored.
|
||||
if len(store.createdCalls) != 1 {
|
||||
t.Fatal("expected 1 Create call")
|
||||
}
|
||||
if store.createdCalls[0].Code != translatedCode {
|
||||
t.Errorf("stored code: got %q, want %q", store.createdCalls[0].Code, translatedCode)
|
||||
}
|
||||
if store.createdCalls[0].Language != "python" {
|
||||
t.Errorf("stored language: got %q, want python", store.createdCalls[0].Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAndPollinate_sameLanguage_noTranslation(t *testing.T) {
|
||||
// All islands speak Go.
|
||||
store := newMockStore()
|
||||
seedIsland(store, evolverdb.IslandAlpha, "go", 100.0, 50)
|
||||
seedIsland(store, evolverdb.IslandBeta, "go", 80.0, 10)
|
||||
seedIsland(store, evolverdb.IslandGamma, "go", 70.0, 10)
|
||||
seedIsland(store, evolverdb.IslandDelta, "go", 60.0, 10)
|
||||
|
||||
llmClient := &mockLLM{}
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
checker := &Checker{store: store, client: llmClient, rng: rng}
|
||||
|
||||
prevGens := make(map[string]int)
|
||||
results, err := checker.CheckAndPollinate(context.Background(), prevGens, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckAndPollinate: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(results))
|
||||
}
|
||||
|
||||
if results[0].Translated {
|
||||
t.Error("should not translate when languages match")
|
||||
}
|
||||
if llmClient.generateCalled {
|
||||
t.Error("LLM should not be called when languages match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAndPollinate_hundredGenerations_twoEvents(t *testing.T) {
|
||||
// Alpha at gen 100, beta at gen 50, others below.
|
||||
store := newMockStore()
|
||||
seedIsland(store, evolverdb.IslandAlpha, "go", 100.0, 100)
|
||||
seedIsland(store, evolverdb.IslandBeta, "go", 80.0, 50)
|
||||
seedIsland(store, evolverdb.IslandGamma, "go", 70.0, 30)
|
||||
seedIsland(store, evolverdb.IslandDelta, "go", 60.0, 20)
|
||||
|
||||
llmClient := &mockLLM{}
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
checker := &Checker{store: store, client: llmClient, rng: rng}
|
||||
|
||||
prevGens := make(map[string]int)
|
||||
results, err := checker.CheckAndPollinate(context.Background(), prevGens, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckAndPollinate: %v", err)
|
||||
}
|
||||
|
||||
// Alpha crosses 50 and 100 = 2 events. Beta crosses 50 = 1 event. Total = 3.
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("expected 3 events (alpha×2 + beta×1), got %d", len(results))
|
||||
}
|
||||
|
||||
alphaCount := 0
|
||||
betaCount := 0
|
||||
for _, r := range results {
|
||||
if r.SourceIsland == evolverdb.IslandAlpha {
|
||||
alphaCount++
|
||||
}
|
||||
if r.SourceIsland == evolverdb.IslandBeta {
|
||||
betaCount++
|
||||
}
|
||||
}
|
||||
if alphaCount != 2 {
|
||||
t.Errorf("alpha: expected 2 events (gen 50 and 100), got %d", alphaCount)
|
||||
}
|
||||
if betaCount != 1 {
|
||||
t.Errorf("beta: expected 1 event (gen 50), got %d", betaCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAndPollinate_emptyIsland_noEvent(t *testing.T) {
|
||||
store := newMockStore()
|
||||
seedIsland(store, evolverdb.IslandAlpha, "go", 100.0, 50)
|
||||
// Beta, gamma, delta have no programs.
|
||||
|
||||
llmClient := &mockLLM{}
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
checker := &Checker{store: store, client: llmClient, rng: rng}
|
||||
|
||||
prevGens := make(map[string]int)
|
||||
results, err := checker.CheckAndPollinate(context.Background(), prevGens, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckAndPollinate: %v", err)
|
||||
}
|
||||
|
||||
// Only alpha triggers (it has gen 50). The others have gen 0, no boundary.
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 event (alpha only), got %d", len(results))
|
||||
}
|
||||
if results[0].SourceIsland != evolverdb.IslandAlpha {
|
||||
t.Errorf("source: got %q, want alpha", results[0].SourceIsland)
|
||||
}
|
||||
}
|
||||
|
||||
// newRandZero creates a deterministic RNG for reproducible tests.
|
||||
func newRandZero() *rand.Rand {
|
||||
return rand.New(rand.NewSource(0))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue