diff --git a/cmd/acb-api/db.go b/cmd/acb-api/db.go index e0db7af..a990263 100644 --- a/cmd/acb-api/db.go +++ b/cmd/acb-api/db.go @@ -66,6 +66,21 @@ CREATE TABLE IF NOT EXISTS rating_history ( PRIMARY KEY (bot_id, match_id) ); CREATE INDEX IF NOT EXISTS idx_rating_history_bot ON rating_history(bot_id, recorded_at); + +CREATE TABLE IF NOT EXISTS programs ( + id BIGSERIAL PRIMARY KEY, + code TEXT NOT NULL, + language VARCHAR(32) NOT NULL, + island VARCHAR(16) NOT NULL, + generation INTEGER NOT NULL DEFAULT 0, + parent_ids JSONB NOT NULL DEFAULT '[]', + behavior_vector DOUBLE PRECISION[] NOT NULL DEFAULT '{}', + fitness DOUBLE PRECISION NOT NULL DEFAULT 0.0, + promoted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_programs_island ON programs(island); +CREATE INDEX IF NOT EXISTS idx_programs_island_fitness ON programs(island, fitness DESC); ` func ensureSchema(ctx context.Context, db *sql.DB) error { diff --git a/cmd/acb-evolver/internal/db/db.go b/cmd/acb-evolver/internal/db/db.go new file mode 100644 index 0000000..533dcb7 --- /dev/null +++ b/cmd/acb-evolver/internal/db/db.go @@ -0,0 +1,44 @@ +// Package db provides database access for the evolution pipeline. +package db + +import ( + "context" + "database/sql" +) + +// schemaSQL creates the programs and validation_log tables with their indexes. +const schemaSQL = ` +CREATE TABLE IF NOT EXISTS programs ( + id BIGSERIAL PRIMARY KEY, + code TEXT NOT NULL, + language VARCHAR(32) NOT NULL, + island VARCHAR(16) NOT NULL, + generation INTEGER NOT NULL DEFAULT 0, + parent_ids JSONB NOT NULL DEFAULT '[]', + behavior_vector DOUBLE PRECISION[] NOT NULL DEFAULT '{}', + fitness DOUBLE PRECISION NOT NULL DEFAULT 0.0, + promoted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_programs_island ON programs(island); +CREATE INDEX IF NOT EXISTS idx_programs_island_fitness ON programs(island, fitness DESC); + +CREATE TABLE IF NOT EXISTS validation_log ( + id BIGSERIAL PRIMARY KEY, + island VARCHAR(16) NOT NULL, + language VARCHAR(32) NOT NULL, + stage VARCHAR(16) NOT NULL, -- last stage attempted: syntax / schema / sandbox + passed BOOLEAN NOT NULL, + error_text TEXT NOT NULL DEFAULT '', + llm_output TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_validation_log_island ON validation_log(island); +CREATE INDEX IF NOT EXISTS idx_validation_log_island_passed ON validation_log(island, passed); +` + +// EnsureSchema creates the programs table if it does not already exist. +func EnsureSchema(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, schemaSQL) + return err +} diff --git a/cmd/acb-evolver/internal/db/programs.go b/cmd/acb-evolver/internal/db/programs.go new file mode 100644 index 0000000..96365da --- /dev/null +++ b/cmd/acb-evolver/internal/db/programs.go @@ -0,0 +1,179 @@ +package db + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/lib/pq" +) + +// Island names for the 4 independent populations. +const ( + IslandAlpha = "alpha" // core-rushing strategies + IslandBeta = "beta" // energy-focused strategies + IslandGamma = "gamma" // defensive strategies + IslandDelta = "delta" // mixed / experimental +) + +// AllIslands is the ordered list of the 4 island names. +var AllIslands = []string{IslandAlpha, IslandBeta, IslandGamma, IslandDelta} + +// Program represents an evolved strategy program stored in the database. +// BehaviorVector is a 2-element slice: [aggression, economy], each in [0, 1]. +type Program struct { + ID int64 + Code string + Language string + Island string + Generation int + ParentIDs []int64 + BehaviorVector []float64 + Fitness float64 + Promoted bool + CreatedAt time.Time +} + +// Store provides CRUD operations for programs. +type Store struct { + db *sql.DB +} + +// NewStore creates a Store backed by the given database connection. +func NewStore(db *sql.DB) *Store { + return &Store{db: db} +} + +// Create inserts a new program and returns its assigned ID. +func (s *Store) Create(ctx context.Context, p *Program) (int64, error) { + parentJSON, err := json.Marshal(p.ParentIDs) + if err != nil { + return 0, fmt.Errorf("marshal parent_ids: %w", err) + } + + var id int64 + err = s.db.QueryRowContext(ctx, ` + INSERT INTO programs (code, language, island, generation, parent_ids, behavior_vector, fitness, promoted) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + p.Code, + p.Language, + p.Island, + p.Generation, + string(parentJSON), + pq.Array(p.BehaviorVector), + p.Fitness, + p.Promoted, + ).Scan(&id) + if err != nil { + return 0, fmt.Errorf("insert program: %w", err) + } + return id, nil +} + +// Get retrieves a program by ID. Returns (nil, nil) if not found. +func (s *Store) Get(ctx context.Context, id int64) (*Program, error) { + p := &Program{} + var parentJSON string + err := s.db.QueryRowContext(ctx, ` + SELECT id, code, language, island, generation, parent_ids, + behavior_vector, fitness, promoted, created_at + FROM programs WHERE id = $1`, id).Scan( + &p.ID, &p.Code, &p.Language, &p.Island, &p.Generation, + &parentJSON, pq.Array(&p.BehaviorVector), &p.Fitness, &p.Promoted, &p.CreatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get program %d: %w", id, err) + } + if err := json.Unmarshal([]byte(parentJSON), &p.ParentIDs); err != nil { + return nil, fmt.Errorf("unmarshal parent_ids: %w", err) + } + return p, nil +} + +// ListByIsland returns all programs on the given island ordered by fitness desc. +func (s *Store) ListByIsland(ctx context.Context, island string) ([]*Program, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, code, language, island, generation, parent_ids, + behavior_vector, fitness, promoted, created_at + FROM programs WHERE island = $1 + ORDER BY fitness DESC`, island) + if err != nil { + return nil, fmt.Errorf("list programs on %s: %w", island, err) + } + defer rows.Close() + + var programs []*Program + for rows.Next() { + p := &Program{} + var parentJSON string + if err := rows.Scan( + &p.ID, &p.Code, &p.Language, &p.Island, &p.Generation, + &parentJSON, pq.Array(&p.BehaviorVector), &p.Fitness, &p.Promoted, &p.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("scan program: %w", err) + } + if err := json.Unmarshal([]byte(parentJSON), &p.ParentIDs); err != nil { + return nil, fmt.Errorf("unmarshal parent_ids: %w", err) + } + programs = append(programs, p) + } + return programs, rows.Err() +} + +// UpdateFitness updates the fitness score and behavior vector of a program. +func (s *Store) UpdateFitness(ctx context.Context, id int64, fitness float64, behaviorVec []float64) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE programs SET fitness = $1, behavior_vector = $2 WHERE id = $3`, + fitness, pq.Array(behaviorVec), id, + ) + if err != nil { + return fmt.Errorf("update fitness for program %d: %w", id, err) + } + return nil +} + +// SetPromoted marks a program as promoted to the live bot fleet. +func (s *Store) SetPromoted(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `UPDATE programs SET promoted = TRUE WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("set promoted for program %d: %w", id, err) + } + return nil +} + +// CountByIsland returns the number of programs on each island. +func (s *Store) CountByIsland(ctx context.Context) (map[string]int, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT island, COUNT(*) FROM programs GROUP BY island`) + if err != nil { + return nil, fmt.Errorf("count by island: %w", err) + } + defer rows.Close() + + counts := make(map[string]int) + for rows.Next() { + var island string + var count int + if err := rows.Scan(&island, &count); err != nil { + return nil, fmt.Errorf("scan island count: %w", err) + } + counts[island] = count + } + return counts, rows.Err() +} + +// TotalCount returns the total number of programs across all islands. +func (s *Store) TotalCount(ctx context.Context) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM programs`).Scan(&n) + if err != nil { + return 0, fmt.Errorf("total count: %w", err) + } + return n, nil +} diff --git a/cmd/acb-evolver/internal/db/programs_test.go b/cmd/acb-evolver/internal/db/programs_test.go new file mode 100644 index 0000000..55de690 --- /dev/null +++ b/cmd/acb-evolver/internal/db/programs_test.go @@ -0,0 +1,266 @@ +package db + +import ( + "context" + "database/sql" + "os" + "testing" + + _ "github.com/lib/pq" +) + +// openTestDB opens a PostgreSQL connection using ACB_TEST_DATABASE_URL. +// Tests that call this function are skipped when the env var is absent. +func openTestDB(t *testing.T) *sql.DB { + t.Helper() + dsn := os.Getenv("ACB_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("ACB_TEST_DATABASE_URL not set; skipping DB integration test") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("open test DB: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +// setupTestSchema creates the programs table and registers cleanup to drop it. +func setupTestSchema(t *testing.T, db *sql.DB) { + t.Helper() + ctx := context.Background() + if err := EnsureSchema(ctx, db); err != nil { + t.Fatalf("ensure schema: %v", err) + } + t.Cleanup(func() { + db.ExecContext(ctx, `DROP TABLE IF EXISTS programs`) + }) +} + +func TestCreate_Get(t *testing.T) { + db := openTestDB(t) + setupTestSchema(t, db) + s := NewStore(db) + ctx := context.Background() + + p := &Program{ + Code: "func strategy() {}", + Language: "go", + Island: IslandAlpha, + Generation: 0, + ParentIDs: []int64{}, + BehaviorVector: []float64{0.9, 0.2}, + Fitness: 0.0, + Promoted: false, + } + + id, err := s.Create(ctx, p) + if err != nil { + t.Fatalf("Create: %v", err) + } + if id <= 0 { + t.Fatalf("expected positive ID, got %d", id) + } + + got, err := s.Get(ctx, id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got == nil { + t.Fatal("Get returned nil for existing program") + } + if got.Code != p.Code { + t.Errorf("Code: got %q, want %q", got.Code, p.Code) + } + if got.Language != p.Language { + t.Errorf("Language: got %q, want %q", got.Language, p.Language) + } + if got.Island != p.Island { + t.Errorf("Island: got %q, want %q", got.Island, p.Island) + } + if len(got.BehaviorVector) != 2 || got.BehaviorVector[0] != 0.9 || got.BehaviorVector[1] != 0.2 { + t.Errorf("BehaviorVector: got %v, want [0.9 0.2]", got.BehaviorVector) + } +} + +func TestGet_NotFound(t *testing.T) { + db := openTestDB(t) + setupTestSchema(t, db) + s := NewStore(db) + ctx := context.Background() + + got, err := s.Get(ctx, 999999) + if err != nil { + t.Fatalf("Get non-existent: %v", err) + } + if got != nil { + t.Error("expected nil for non-existent program") + } +} + +func TestListByIsland(t *testing.T) { + db := openTestDB(t) + setupTestSchema(t, db) + s := NewStore(db) + ctx := context.Background() + + programs := []*Program{ + {Code: "a", Language: "go", Island: IslandAlpha, BehaviorVector: []float64{0.9, 0.2}, Fitness: 10.0, ParentIDs: []int64{}}, + {Code: "b", Language: "go", Island: IslandAlpha, BehaviorVector: []float64{0.8, 0.3}, Fitness: 5.0, ParentIDs: []int64{}}, + {Code: "c", Language: "go", Island: IslandBeta, BehaviorVector: []float64{0.1, 0.9}, Fitness: 8.0, ParentIDs: []int64{}}, + } + for _, p := range programs { + if _, err := s.Create(ctx, p); err != nil { + t.Fatalf("Create: %v", err) + } + } + + alphaList, err := s.ListByIsland(ctx, IslandAlpha) + if err != nil { + t.Fatalf("ListByIsland: %v", err) + } + if len(alphaList) != 2 { + t.Fatalf("expected 2 programs on alpha, got %d", len(alphaList)) + } + // Verify descending fitness order + if alphaList[0].Fitness < alphaList[1].Fitness { + t.Error("expected programs ordered by fitness DESC") + } + + betaList, err := s.ListByIsland(ctx, IslandBeta) + if err != nil { + t.Fatalf("ListByIsland beta: %v", err) + } + if len(betaList) != 1 { + t.Fatalf("expected 1 program on beta, got %d", len(betaList)) + } + + // Empty island returns empty slice (not an error) + gammaList, err := s.ListByIsland(ctx, IslandGamma) + if err != nil { + t.Fatalf("ListByIsland gamma: %v", err) + } + if len(gammaList) != 0 { + t.Errorf("expected empty gamma island, got %d programs", len(gammaList)) + } +} + +func TestUpdateFitness(t *testing.T) { + db := openTestDB(t) + setupTestSchema(t, db) + s := NewStore(db) + ctx := context.Background() + + id, err := s.Create(ctx, &Program{ + Code: "x", Language: "go", Island: IslandDelta, + BehaviorVector: []float64{0.3, 0.4}, ParentIDs: []int64{}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := s.UpdateFitness(ctx, id, 42.5, []float64{0.35, 0.45}); err != nil { + t.Fatalf("UpdateFitness: %v", err) + } + + got, _ := s.Get(ctx, id) + if got.Fitness != 42.5 { + t.Errorf("Fitness: got %f, want 42.5", got.Fitness) + } + if len(got.BehaviorVector) != 2 || got.BehaviorVector[0] != 0.35 { + t.Errorf("BehaviorVector after update: got %v", got.BehaviorVector) + } +} + +func TestSetPromoted(t *testing.T) { + db := openTestDB(t) + setupTestSchema(t, db) + s := NewStore(db) + ctx := context.Background() + + id, err := s.Create(ctx, &Program{ + Code: "y", Language: "rust", Island: IslandGamma, + BehaviorVector: []float64{0.7, 0.3}, ParentIDs: []int64{}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + got, _ := s.Get(ctx, id) + if got.Promoted { + t.Fatal("new program should not be promoted") + } + + if err := s.SetPromoted(ctx, id); err != nil { + t.Fatalf("SetPromoted: %v", err) + } + + got, _ = s.Get(ctx, id) + if !got.Promoted { + t.Error("program should be promoted after SetPromoted") + } +} + +func TestCountByIsland(t *testing.T) { + db := openTestDB(t) + setupTestSchema(t, db) + s := NewStore(db) + ctx := context.Background() + + for _, prog := range []*Program{ + {Code: "1", Language: "go", Island: IslandAlpha, BehaviorVector: []float64{0.9, 0.1}, ParentIDs: []int64{}}, + {Code: "2", Language: "go", Island: IslandAlpha, BehaviorVector: []float64{0.8, 0.2}, ParentIDs: []int64{}}, + {Code: "3", Language: "go", Island: IslandBeta, BehaviorVector: []float64{0.1, 0.9}, ParentIDs: []int64{}}, + } { + if _, err := s.Create(ctx, prog); err != nil { + t.Fatalf("Create: %v", err) + } + } + + counts, err := s.CountByIsland(ctx) + if err != nil { + t.Fatalf("CountByIsland: %v", err) + } + if counts[IslandAlpha] != 2 { + t.Errorf("alpha count: got %d, want 2", counts[IslandAlpha]) + } + if counts[IslandBeta] != 1 { + t.Errorf("beta count: got %d, want 1", counts[IslandBeta]) + } +} + +func TestParentIDs_Roundtrip(t *testing.T) { + db := openTestDB(t) + setupTestSchema(t, db) + s := NewStore(db) + ctx := context.Background() + + // First create two parent programs + p1, _ := s.Create(ctx, &Program{Code: "parent1", Language: "go", Island: IslandAlpha, BehaviorVector: []float64{0.9, 0.2}, ParentIDs: []int64{}}) + p2, _ := s.Create(ctx, &Program{Code: "parent2", Language: "go", Island: IslandAlpha, BehaviorVector: []float64{0.8, 0.3}, ParentIDs: []int64{}}) + + // Create child with both parents + childID, err := s.Create(ctx, &Program{ + Code: "child", Language: "go", Island: IslandAlpha, + Generation: 1, + ParentIDs: []int64{p1, p2}, + BehaviorVector: []float64{0.85, 0.25}, + }) + if err != nil { + t.Fatalf("Create child: %v", err) + } + + child, err := s.Get(ctx, childID) + if err != nil { + t.Fatalf("Get child: %v", err) + } + if len(child.ParentIDs) != 2 { + t.Fatalf("expected 2 parent IDs, got %d", len(child.ParentIDs)) + } + if child.ParentIDs[0] != p1 || child.ParentIDs[1] != p2 { + t.Errorf("ParentIDs: got %v, want [%d %d]", child.ParentIDs, p1, p2) + } + if child.Generation != 1 { + t.Errorf("Generation: got %d, want 1", child.Generation) + } +} diff --git a/cmd/acb-evolver/internal/db/seed.go b/cmd/acb-evolver/internal/db/seed.go new file mode 100644 index 0000000..eeb6def --- /dev/null +++ b/cmd/acb-evolver/internal/db/seed.go @@ -0,0 +1,126 @@ +package db + +import ( + "context" + _ "embed" + "fmt" +) + +//go:embed seeds/gatherer_strategy.go.txt +var gathererCode string + +//go:embed seeds/rusher_strategy.rs.txt +var rusherCode string + +//go:embed seeds/swarm_strategy.ts.txt +var swarmCode string + +//go:embed seeds/guardian_strategy.php.txt +var guardianCode string + +//go:embed seeds/hunter_strategy.java.txt +var hunterCode string + +//go:embed seeds/random_main.py.txt +var randomCode string + +// seedProgram describes a built-in strategy bot used to bootstrap the +// programs database. +type seedProgram struct { + name string + language string + island string + aggression float64 // behavior_vector[0] + economy float64 // behavior_vector[1] + code string +} + +// seeds is the initial population of 6 built-in strategy bots distributed +// across all 4 islands. Each bot is assigned a behavior vector that captures +// its play-style on the aggression × economy axes. +var seeds = []seedProgram{ + // beta island – economic strategies + { + name: "gatherer", + language: "go", + island: IslandBeta, + aggression: 0.1, + economy: 0.9, + code: gathererCode, + }, + { + name: "guardian", + language: "php", + island: IslandBeta, + aggression: 0.2, + economy: 0.6, + code: guardianCode, + }, + // alpha island – aggressive strategies + { + name: "rusher", + language: "rust", + island: IslandAlpha, + aggression: 0.9, + economy: 0.2, + code: rusherCode, + }, + { + name: "swarm", + language: "typescript", + island: IslandAlpha, + aggression: 0.6, + economy: 0.5, + code: swarmCode, + }, + // gamma island – adaptive / hunting strategies + { + name: "hunter", + language: "java", + island: IslandGamma, + aggression: 0.7, + economy: 0.3, + code: hunterCode, + }, + // delta island – baseline / experimental + { + name: "random", + language: "python", + island: IslandDelta, + aggression: 0.3, + economy: 0.4, + code: randomCode, + }, +} + +// SeedPopulation inserts the 6 built-in strategy bots as generation-0 +// programs if the programs table is empty. It is idempotent: a second call +// is a no-op. +func SeedPopulation(ctx context.Context, s *Store) (int, error) { + total, err := s.TotalCount(ctx) + if err != nil { + return 0, fmt.Errorf("check existing programs: %w", err) + } + if total > 0 { + return 0, nil + } + + inserted := 0 + for _, seed := range seeds { + p := &Program{ + Code: seed.code, + Language: seed.language, + Island: seed.island, + Generation: 0, + ParentIDs: []int64{}, + BehaviorVector: []float64{seed.aggression, seed.economy}, + Fitness: 0.0, + Promoted: false, + } + if _, err := s.Create(ctx, p); err != nil { + return inserted, fmt.Errorf("seed %s: %w", seed.name, err) + } + inserted++ + } + return inserted, nil +} diff --git a/cmd/acb-evolver/internal/db/seeds/gatherer_strategy.go.txt b/cmd/acb-evolver/internal/db/seeds/gatherer_strategy.go.txt new file mode 100644 index 0000000..48ea20d --- /dev/null +++ b/cmd/acb-evolver/internal/db/seeds/gatherer_strategy.go.txt @@ -0,0 +1,298 @@ +package main + +import ( + "container/list" +) + +// GathererStrategy implements energy-focused gameplay with combat avoidance. +type GathererStrategy struct { + // No persistent state needed - strategy is stateless per turn +} + +// NewGathererStrategy creates a new gatherer strategy. +func NewGathererStrategy() *GathererStrategy { + return &GathererStrategy{} +} + +// ComputeMoves calculates the best moves for the current turn. +func (s *GathererStrategy) ComputeMoves(state *GameState) []Move { + if len(state.Bots) == 0 { + return nil + } + + myID := state.You.ID + config := state.Config + + // Separate my bots from enemy bots + myBots := make([]VisibleBot, 0) + enemyBots := make([]VisibleBot, 0) + for _, bot := range state.Bots { + if bot.Owner == myID { + myBots = append(myBots, bot) + } else { + enemyBots = append(enemyBots, bot) + } + } + + // Build enemy positions map for quick lookup + enemyPositions := make(map[Position]bool) + for _, enemy := range enemyBots { + enemyPositions[enemy.Position] = true + } + + // Build energy positions map + energyPositions := make(map[Position]bool) + for _, e := range state.Energy { + energyPositions[e] = true + } + + // For each of my bots, find the best move + moves := make([]Move, 0, len(myBots)) + usedEnergy := make(map[Position]bool) // Track energy already targeted + + for _, bot := range myBots { + move := s.computeBotMove(bot, myBots, enemyBots, enemyPositions, + energyPositions, usedEnergy, config) + if move != nil { + moves = append(moves, *move) + // Mark energy as targeted if bot will collect it + if energyPositions[move.Position] || energyPositions[simulateMove(bot.Position, move.Direction, config)] { + usedEnergy[simulateMove(bot.Position, move.Direction, config)] = true + } + } + } + + return moves +} + +// computeBotMove calculates the best move for a single bot. +func (s *GathererStrategy) computeBotMove( + bot VisibleBot, + myBots, enemyBots []VisibleBot, + enemyPositions, energyPositions, usedEnergy map[Position]bool, + config GameConfig, +) *Move { + // First check if we should flee from enemies + if s.shouldFlee(bot.Position, enemyBots, config) { + fleeDir := s.getFleeDirection(bot.Position, enemyBots, config) + if fleeDir != "" { + return &Move{ + Position: bot.Position, + Direction: fleeDir, + } + } + } + + // Try to find nearest untargeted energy + nearestEnergy, path := s.findNearestEnergy(bot.Position, energyPositions, usedEnergy, enemyPositions, config) + if path != nil && len(path) > 0 { + // Move towards the energy + return &Move{ + Position: bot.Position, + Direction: path[0], + } + } + + // No energy visible or reachable - spread out to explore + return s.getExploreMove(bot.Position, myBots, enemyPositions, config) +} + +// shouldFlee returns true if the bot should flee from nearby enemies. +func (s *GathererStrategy) shouldFlee(pos Position, enemies []VisibleBot, config GameConfig) bool { + for _, enemy := range enemies { + dist2 := distance2(pos, enemy.Position, config) + // Flee if enemy is within attack range + 2 tiles buffer + if dist2 <= config.AttackRadius2+4 { + return true + } + } + return false +} + +// getFleeDirection returns the best direction to flee from enemies. +func (s *GathererStrategy) getFleeDirection(pos Position, enemies []VisibleBot, config GameConfig) Direction { + // Calculate the center of mass of enemies + enemyCenter := Position{Row: 0, Col: 0} + for _, enemy := range enemies { + enemyCenter.Row += enemy.Position.Row + enemyCenter.Col += enemy.Position.Col + } + if len(enemies) > 0 { + enemyCenter.Row /= len(enemies) + enemyCenter.Col /= len(enemies) + } + + // Move away from enemy center + dr := pos.Row - enemyCenter.Row + dc := pos.Col - enemyCenter.Col + + // Normalize direction + if dr > 0 { + return DirS + } else if dr < 0 { + return DirN + } else if dc > 0 { + return DirE + } else if dc < 0 { + return DirW + } + + // Default: move North + return DirN +} + +// findNearestEnergy finds the nearest untargeted energy using BFS. +func (s *GathererStrategy) findNearestEnergy( + start Position, + energyPositions, usedEnergy, enemyPositions map[Position]bool, + config GameConfig, +) (Position, []Direction) { + type queueItem struct { + pos Position + path []Direction + } + + visited := make(map[Position]bool) + queue := list.New() + queue.PushBack(queueItem{pos: start, path: []Direction{}}) + + _ = nearestEnergy // Track found position (unused but semantically meaningful) + var bestPath []Direction + + for queue.Len() > 0 { + item := queue.Remove(queue.Front()).(queueItem) + pos := item.pos + path := item.path + + if visited[pos] { + continue + } + visited[pos] = true + + // Check if this position has untargeted energy + if energyPositions[pos] && !usedEnergy[pos] { + nearestEnergy = pos + bestPath = path + break + } + + // Don't path through enemy-adjacent tiles + if len(path) > 0 && s.isNearEnemy(pos, enemyPositions, config) { + continue + } + + // Explore neighbors + directions := []Direction{DirN, DirE, DirS, DirW} + for _, dir := range directions { + nextPos := simulateMove(pos, dir, config) + if !visited[nextPos] { + newPath := make([]Direction, len(path)+1) + copy(newPath, path) + newPath[len(path)] = dir + queue.PushBack(queueItem{pos: nextPos, path: newPath}) + } + } + } + + return nearestEnergy, bestPath +} + +// isNearEnemy checks if a position is adjacent to any enemy. +func (s *GathererStrategy) isNearEnemy(pos Position, enemyPositions map[Position]bool, config GameConfig) bool { + directions := []Direction{DirN, DirE, DirS, DirW} + for _, dir := range directions { + adj := simulateMove(pos, dir, config) + if enemyPositions[adj] { + return true + } + } + return false +} + +// getExploreMove returns a move for exploring when no energy is visible. +func (s *GathererStrategy) getExploreMove( + pos Position, + myBots []VisibleBot, + enemyPositions map[Position]bool, + config GameConfig, +) *Move { + // Calculate direction away from other friendly bots (spread out) + directions := []Direction{DirN, DirE, DirS, DirW} + bestDir := DirN + bestScore := -999999 + + for _, dir := range directions { + newPos := simulateMove(pos, dir, config) + + // Skip if moving towards enemy + if s.isNearEnemy(newPos, enemyPositions, config) { + continue + } + + // Score based on distance from other bots (prefer spreading out) + score := 0 + for _, other := range myBots { + if other.Position != pos { + dist := distance2(newPos, other.Position, config) + score += int(dist) // Higher is better (further from others) + } + } + + if score > bestScore { + bestScore = score + bestDir = dir + } + } + + return &Move{ + Position: pos, + Direction: bestDir, + } +} + +// distance2 calculates squared Euclidean distance with toroidal wrapping. +func distance2(a, b Position, config GameConfig) int { + dr := abs(a.Row - b.Row) + dc := abs(a.Col - b.Col) + + // Apply toroidal wrapping + if dr > config.Rows/2 { + dr = config.Rows - dr + } + if dc > config.Cols/2 { + dc = config.Cols - dc + } + + return dr*dr + dc*dc +} + +// simulateMove returns the new position after moving in a direction. +func simulateMove(pos Position, dir Direction, config GameConfig) Position { + var newRow, newCol int + + switch dir { + case DirN: + newRow = (pos.Row - 1 + config.Rows) % config.Rows + newCol = pos.Col + case DirE: + newRow = pos.Row + newCol = (pos.Col + 1) % config.Cols + case DirS: + newRow = (pos.Row + 1) % config.Rows + newCol = pos.Col + case DirW: + newRow = pos.Row + newCol = (pos.Col - 1 + config.Cols) % config.Cols + default: + return pos + } + + return Position{Row: newRow, Col: newCol} +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/cmd/acb-evolver/internal/db/seeds/guardian_strategy.php.txt b/cmd/acb-evolver/internal/db/seeds/guardian_strategy.php.txt new file mode 100644 index 0000000..6b9e77b --- /dev/null +++ b/cmd/acb-evolver/internal/db/seeds/guardian_strategy.php.txt @@ -0,0 +1,364 @@ +you->id; + $config = $state->config; + + // Separate my bots from enemies + $myBots = []; + $enemyBots = []; + foreach ($state->bots as $bot) { + if ($bot->owner === $myId) { + $myBots[] = $bot; + } else { + $enemyBots[] = $bot; + } + } + + if (empty($myBots)) { + return []; + } + + // Find my cores and enemy cores + $myCores = []; + $enemyCores = []; + foreach ($state->cores as $core) { + if ($core->owner === $myId && $core->active) { + $myCores[] = $core; + } elseif ($core->active) { + $enemyCores[] = $core; + } + } + + // Build wall lookup + $walls = $this->buildPositionSet($state->walls); + + // Build enemy position lookup + $enemyPositions = $this->buildPositionSet(array_map(fn($b) => $b->position, $enemyBots)); + + // Build energy position set + $energyPositions = $this->buildPositionSet($state->energy); + + // Assign roles to bots + $moves = []; + $usedEnergy = []; + $assignedPositions = []; + + // First pass: assign defenders to cores + $defenders = $this->assignDefenders($myBots, $myCores, $enemyBots, $config); + + // Second pass: assign gatherers to nearby energy + foreach ($myBots as $bot) { + if (isset($assignedPositions[$this->posKey($bot->position)])) { + continue; + } + + // Check if this bot should be a defender + if (isset($defenders[$this->posKey($bot->position)])) { + $move = $this->computeDefenderMove($bot, $defenders[$this->posKey($bot->position)], $enemyBots, $walls, $config); + } elseif ($this->shouldGather($bot, $myCores, $config)) { + $move = $this->computeGatherMove($bot, $energyPositions, $usedEnergy, $enemyPositions, $walls, $myCores, $config); + } else { + // Scout - explore cautiously + $move = $this->computeScoutMove($bot, $enemyPositions, $walls, $config); + } + + if ($move) { + $moves[] = $move; + $assignedPositions[$this->posKey($bot->position)] = true; + } + } + + return $moves; + } + + /** + * Assign bots to defend cores based on threat level + */ + private function assignDefenders(array $myBots, array $myCores, array $enemyBots, GameConfig $config): array { + $defenders = []; + + if (empty($myCores)) { + return $defenders; + } + + // Calculate threat level for each core + $coreThreats = []; + foreach ($myCores as $core) { + $threat = 0; + foreach ($enemyBots as $enemy) { + $dist2 = $enemy->position->distance2($core->position, $config->rows, $config->cols); + if ($dist2 <= 100) { // Within 10 tiles + $threat += 10 - (int)sqrt($dist2); + } + } + $coreThreats[$this->posKey($core->position)] = $threat; + } + + // Assign bots to cores based on threat and proximity + foreach ($myBots as $bot) { + $bestCore = null; + $bestScore = PHP_INT_MAX; + + foreach ($myCores as $core) { + $dist2 = $bot->position->distance2($core->position, $config->rows, $config->cols); + $threat = $coreThreats[$this->posKey($core->position)]; + + // Prioritize threatened cores + $score = $dist2 - $threat * 100; + + if ($score < $bestScore) { + $bestScore = $score; + $bestCore = $core; + } + } + + if ($bestCore) { + $defenders[$this->posKey($bot->position)] = $bestCore; + } + } + + return $defenders; + } + + /** + * Compute move for a defender bot + */ + private function computeDefenderMove(VisibleBot $bot, VisibleCore $core, array $enemyBots, array $walls, GameConfig $config): ?Move { + $rows = $config->rows; + $cols = $config->cols; + + // Find nearest enemy within threat range + $nearestEnemy = null; + $nearestEnemyDist = PHP_INT_MAX; + foreach ($enemyBots as $enemy) { + $dist2 = $bot->position->distance2($enemy->position, $rows, $cols); + if ($dist2 < $nearestEnemyDist && $dist2 <= 100) { + $nearestEnemyDist = $dist2; + $nearestEnemy = $enemy; + } + } + + // If enemy is approaching, intercept + if ($nearestEnemy && $nearestEnemyDist <= 50) { + $dir = $this->getDirectionToward($bot->position, $nearestEnemy->position, $walls, $config); + if ($dir) { + return new Move($bot->position, $dir); + } + } + + // Otherwise, maintain perimeter around core + $distToCore = $bot->position->distance2($core->position, $rows, $cols); + + if ($distToCore > self::PERIMETER_RADIUS * self::PERIMETER_RADIUS) { + // Move toward core + $dir = $this->getDirectionToward($bot->position, $core->position, $walls, $config); + if ($dir) { + return new Move($bot->position, $dir); + } + } + + // Stay in place or patrol + return null; + } + + /** + * Check if bot should gather energy + */ + private function shouldGather(VisibleBot $bot, array $myCores, GameConfig $config): bool { + foreach ($myCores as $core) { + $dist2 = $bot->position->distance2($core->position, $config->rows, $config->cols); + if ($dist2 <= self::SAFE_ZONE_RADIUS * self::SAFE_ZONE_RADIUS) { + return true; + } + } + return false; + } + + /** + * Compute move for a gatherer bot + */ + private function computeGatherMove(VisibleBot $bot, array $energyPositions, array &$usedEnergy, array $enemyPositions, array $walls, array $myCores, GameConfig $config): ?Move { + // Find nearest untargeted energy within safe zone + $bestEnergy = null; + $bestDist = PHP_INT_MAX; + + foreach ($energyPositions as $posKey => $pos) { + if (isset($usedEnergy[$posKey])) { + continue; + } + + // Check if energy is within safe zone of any core + $inSafeZone = false; + foreach ($myCores as $core) { + $dist2 = $pos->distance2($core->position, $config->rows, $config->cols); + if ($dist2 <= self::SAFE_ZONE_RADIUS * self::SAFE_ZONE_RADIUS) { + $inSafeZone = true; + break; + } + } + + if (!$inSafeZone) { + continue; + } + + $dist2 = $bot->position->distance2($pos, $config->rows, $config->cols); + if ($dist2 < $bestDist) { + $bestDist = $dist2; + $bestEnergy = $pos; + } + } + + if ($bestEnergy) { + $usedEnergy[$this->posKey($bestEnergy)] = true; + $dir = $this->getDirectionToward($bot->position, $bestEnergy, $walls, $config); + if ($dir) { + return new Move($bot->position, $dir); + } + } + + return null; + } + + /** + * Compute move for a scout bot + */ + private function computeScoutMove(VisibleBot $bot, array $enemyPositions, array $walls, GameConfig $config): ?Move { + // Move away from enemies if too close + foreach ($enemyPositions as $posKey => $pos) { + $dist2 = $bot->position->distance2($pos, $config->rows, $config->cols); + if ($dist2 <= $config->attackRadius2 + 4) { + $dir = $this->getDirectionAway($bot->position, $pos, $walls, $config); + if ($dir) { + return new Move($bot->position, $dir); + } + } + } + + // Explore - move toward unexplored areas + $bestDir = null; + $bestScore = -1; + + foreach (self::DIRECTIONS as $dir) { + $newPos = $bot->position->moveToward($dir, $config->rows, $config->cols); + $posKey = $this->posKey($newPos); + + if (isset($walls[$posKey]) || isset($enemyPositions[$posKey])) { + continue; + } + + // Prefer directions that move toward center of map (more exploration) + $centerRow = $config->rows / 2; + $centerCol = $config->cols / 2; + $distToCenter = abs($newPos->row - $centerRow) + abs($newPos->col - $centerCol); + + // Prefer edges for exploration + $edgeDist = min($newPos->row, $newPos->col, $config->rows - $newPos->row, $config->cols - $newPos->col); + $score = $edgeDist < 10 ? 10 - $edgeDist : 0; + + if ($score > $bestScore) { + $bestScore = $score; + $bestDir = $dir; + } + } + + if ($bestDir) { + return new Move($bot->position, $bestDir); + } + + return null; + } + + /** + * Get direction toward a target position using simple greedy approach + */ + private function getDirectionToward(Position $from, Position $to, array $walls, GameConfig $config): ?string { + $rows = $config->rows; + $cols = $config->cols; + + $bestDir = null; + $bestDist = PHP_INT_MAX; + + foreach (self::DIRECTIONS as $dir) { + $newPos = $from->moveToward($dir, $rows, $cols); + + if (isset($walls[$this->posKey($newPos)])) { + continue; + } + + $dist2 = $newPos->distance2($to, $rows, $cols); + if ($dist2 < $bestDist) { + $bestDist = $dist2; + $bestDir = $dir; + } + } + + return $bestDir; + } + + /** + * Get direction away from a threat + */ + private function getDirectionAway(Position $from, Position $threat, array $walls, GameConfig $config): ?string { + $rows = $config->rows; + $cols = $config->cols; + + $bestDir = null; + $bestDist = 0; + + foreach (self::DIRECTIONS as $dir) { + $newPos = $from->moveToward($dir, $rows, $cols); + + if (isset($walls[$this->posKey($newPos)])) { + continue; + } + + $dist2 = $newPos->distance2($threat, $rows, $cols); + if ($dist2 > $bestDist) { + $bestDist = $dist2; + $bestDir = $dir; + } + } + + return $bestDir; + } + + /** + * Build a set of positions for O(1) lookup + */ + private function buildPositionSet(array $positions): array { + $set = []; + foreach ($positions as $pos) { + $set[$this->posKey($pos)] = $pos; + } + return $set; + } + + /** + * Create a unique key for a position + */ + private function posKey(Position $pos): string { + return "{$pos->row},{$pos->col}"; + } +} diff --git a/cmd/acb-evolver/internal/db/seeds/hunter_strategy.java.txt b/cmd/acb-evolver/internal/db/seeds/hunter_strategy.java.txt new file mode 100644 index 0000000..ed16d47 --- /dev/null +++ b/cmd/acb-evolver/internal/db/seeds/hunter_strategy.java.txt @@ -0,0 +1,394 @@ +package com.acb.hunter; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * HunterBot strategy: target isolated enemies for efficient kills. + * + * Strategy: Target isolated enemy bots for efficient kills. + * - Identify enemy bots that are >=4 tiles from their nearest friendly bot (isolated targets) + * - Send pairs of bots to intercept isolated enemies (2v1 wins cleanly) + * - If no isolated targets, default to gatherer behavior + * - Maintain a map of known enemy positions across turns, predict movement + * - Avoid engaging formations of 3+ enemy bots + * - Opportunistic energy collection when not actively hunting + */ +public class HunterStrategy { + private static final int ISOLATION_THRESHOLD = 16; // Squared distance (4 tiles) + private static final int FORMATION_SIZE = 3; // Avoid groups of 3+ enemies + + // Track known enemy positions for prediction + private final Map enemyTrackers = new HashMap<>(); + + /** + * Compute moves for all owned bots + */ + public List computeMoves(GameState state) { + int myId = state.getYou().getId(); + GameConfig config = state.getConfig(); + int rows = config.getRows(); + int cols = config.getCols(); + + // Separate my bots from enemies + List myBots = new ArrayList<>(); + List enemyBots = new ArrayList<>(); + + for (VisibleBot bot : state.getBots()) { + if (bot.getOwner() == myId) { + myBots.add(bot); + } else { + enemyBots.add(bot); + } + } + + if (myBots.isEmpty()) { + return Collections.emptyList(); + } + + // Update enemy trackers + updateEnemyTrackers(enemyBots, rows, cols); + + // Build position lookups + Set walls = buildPositionSet(state.getWalls()); + Set enemyPositions = buildPositionSet( + enemyBots.stream().map(VisibleBot::getPosition).collect(Collectors.toList()) + ); + Set myBotPositions = buildPositionSet( + myBots.stream().map(VisibleBot::getPosition).collect(Collectors.toList()) + ); + + // Find isolated enemy targets + List isolatedEnemies = findIsolatedEnemies(enemyBots, rows, cols); + + // Find energy positions + Set energyPositions = buildPositionSet(state.getEnergy()); + + // Assign bots to targets + List moves = new ArrayList<>(); + Set usedEnergy = new HashSet<>(); + Set assignedTargets = new HashSet<>(); + + // First, assign hunters to isolated enemies + Map hunterAssignments = assignHunters(myBots, isolatedEnemies, rows, cols); + + for (Map.Entry entry : hunterAssignments.entrySet()) { + VisibleBot hunter = entry.getKey(); + VisibleBot target = entry.getValue(); + + // Get predicted position of target + Position predictedPos = predictPosition(target, rows, cols); + assignedTargets.add(predictedPos); + + Move move = computeHunterMove(hunter, predictedPos, enemyPositions, walls, myBotPositions, rows, cols); + if (move != null) { + moves.add(move); + // Mark this bot as assigned + myBotPositions.remove(hunter.getPosition().key()); + } + } + + // Second, assign remaining bots to gather or explore + for (VisibleBot bot : myBots) { + if (!myBotPositions.contains(bot.getPosition().key())) { + continue; // Already assigned + } + + Move move; + if (!energyPositions.isEmpty()) { + move = computeGatherMove(bot, energyPositions, usedEnergy, enemyPositions, walls, rows, cols); + } else { + move = computeExploreMove(bot, enemyPositions, walls, rows, cols); + } + + if (move != null) { + moves.add(move); + } + } + + return moves; + } + + /** + * Update enemy position trackers for prediction + */ + private void updateEnemyTrackers(List enemyBots, int rows, int cols) { + for (VisibleBot bot : enemyBots) { + String key = bot.getPosition().key(); + EnemyTracker tracker = enemyTrackers.computeIfAbsent(key, k -> new EnemyTracker()); + tracker.update(bot.getPosition(), rows, cols); + } + } + + /** + * Find isolated enemy bots (>=4 tiles from nearest friendly) + */ + private List findIsolatedEnemies(List enemyBots, int rows, int cols) { + List isolated = new ArrayList<>(); + + for (VisibleBot bot : enemyBots) { + boolean isIsolated = true; + int nearestDist = Integer.MAX_VALUE; + + for (VisibleBot other : enemyBots) { + if (bot == other) continue; + + int dist = bot.getPosition().distance2(other.getPosition(), rows, cols); + nearestDist = Math.min(nearestDist, dist); + } + + // Isolated if nearest friendly is >= 4 tiles away (squared distance 16) + // or if it's the only enemy bot + if (nearestDist >= ISOLATION_THRESHOLD || enemyBots.size() == 1) { + isolated.add(bot); + } + } + + return isolated; + } + + /** + * Assign hunters to isolated targets using greedy matching + */ + private Map assignHunters( + List myBots, + List isolatedEnemies, + int rows, int cols + ) { + Map assignments = new HashMap<>(); + + if (isolatedEnemies.isEmpty()) { + return assignments; + } + + // Sort my bots by distance to nearest isolated enemy + List availableHunters = new ArrayList<>(myBots); + + // Assign 2 hunters per target when possible + for (VisibleBot target : isolatedEnemies) { + int huntersNeeded = 2; + + // Sort available hunters by distance to target + availableHunters.sort((a, b) -> { + int distA = a.getPosition().distance2(target.getPosition(), rows, cols); + int distB = b.getPosition().distance2(target.getPosition(), rows, cols); + return Integer.compare(distA, distB); + }); + + int assigned = 0; + Iterator iter = availableHunters.iterator(); + while (iter.hasNext() && assigned < huntersNeeded) { + VisibleBot hunter = iter.next(); + assignments.put(hunter, target); + iter.remove(); + assigned++; + } + } + + return assignments; + } + + /** + * Predict where an enemy will be next turn + */ + private Position predictPosition(VisibleBot enemy, int rows, int cols) { + String key = enemy.getPosition().key(); + EnemyTracker tracker = enemyTrackers.get(key); + + if (tracker != null && tracker.hasPrediction()) { + return tracker.predictNextPosition(rows, cols); + } + + return enemy.getPosition(); + } + + /** + * Compute move for a hunter bot toward a target + */ + private Move computeHunterMove( + VisibleBot bot, + Position target, + Set enemyPositions, + Set walls, + Set myBotPositions, + int rows, int cols + ) { + Direction bestDir = null; + int bestScore = Integer.MIN_VALUE; + + for (Direction dir : Direction.all()) { + Position newPos = bot.getPosition().moveToward(dir, rows, cols); + String newPosKey = newPos.key(); + + // Can't move into walls + if (walls.contains(newPosKey)) { + continue; + } + + // Avoid self-collision + if (myBotPositions.contains(newPosKey)) { + continue; + } + + // Score: prefer getting closer to target + int distToTarget = newPos.distance2(target, rows, cols); + int currentDistToTarget = bot.getPosition().distance2(target, rows, cols); + int score = currentDistToTarget - distToTarget; + + // Bonus for being in attack range of target + if (distToTarget <= 5) { // attack_radius2 + score += 20; + } + + // Penalty for moving adjacent to multiple enemies + int adjacentEnemies = 0; + for (String enemyPosKey : enemyPositions) { + String[] parts = enemyPosKey.split(","); + Position enemyPos = new Position(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + if (newPos.distance2(enemyPos, rows, cols) <= 2) { + adjacentEnemies++; + } + } + score -= adjacentEnemies * 10; + + if (score > bestScore) { + bestScore = score; + bestDir = dir; + } + } + + if (bestDir != null) { + return new Move(bot.getPosition(), bestDir); + } + + return null; + } + + /** + * Compute move for a gatherer bot + */ + private Move computeGatherMove( + VisibleBot bot, + Set energyPositions, + Set usedEnergy, + Set enemyPositions, + Set walls, + int rows, int cols + ) { + // Find nearest untargeted energy + Position nearestEnergy = null; + int nearestDist = Integer.MAX_VALUE; + + for (String energyKey : energyPositions) { + if (usedEnergy.contains(energyKey)) continue; + + String[] parts = energyKey.split(","); + Position energyPos = new Position(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + int dist = bot.getPosition().distance2(energyPos, rows, cols); + + if (dist < nearestDist) { + nearestDist = dist; + nearestEnergy = energyPos; + } + } + + if (nearestEnergy != null) { + usedEnergy.add(nearestEnergy.key()); + return computeMoveToward(bot, nearestEnergy, walls, rows, cols); + } + + return null; + } + + /** + * Compute move for exploration + */ + private Move computeExploreMove( + VisibleBot bot, + Set enemyPositions, + Set walls, + int rows, int cols + ) { + // Move toward center if no other target + Position center = new Position(rows / 2, cols / 2); + return computeMoveToward(bot, center, walls, rows, cols); + } + + /** + * Compute move toward a target position + */ + private Move computeMoveToward(VisibleBot bot, Position target, Set walls, int rows, int cols) { + Direction bestDir = null; + int bestDist = Integer.MAX_VALUE; + + for (Direction dir : Direction.all()) { + Position newPos = bot.getPosition().moveToward(dir, rows, cols); + + if (walls.contains(newPos.key())) { + continue; + } + + int dist = newPos.distance2(target, rows, cols); + if (dist < bestDist) { + bestDist = dist; + bestDir = dir; + } + } + + if (bestDir != null) { + return new Move(bot.getPosition(), bestDir); + } + + return null; + } + + /** + * Build a set of position keys for O(1) lookup + */ + private Set buildPositionSet(List positions) { + return positions.stream() + .map(Position::key) + .collect(Collectors.toSet()); + } +} + +/** + * Tracks enemy position history for movement prediction + */ +class EnemyTracker { + private Position lastPosition; + private Position currentPosition; + private int sightings; + + public void update(Position position, int rows, int cols) { + lastPosition = currentPosition; + currentPosition = position; + sightings++; + } + + public boolean hasPrediction() { + return lastPosition != null && currentPosition != null; + } + + public Position predictNextPosition(int rows, int cols) { + if (!hasPrediction()) { + return currentPosition; + } + + // Simple prediction: continue in same direction + int dr = currentPosition.getRow() - lastPosition.getRow(); + int dc = currentPosition.getCol() - lastPosition.getCol(); + + // Handle wrap + if (dr > rows / 2) dr -= rows; + if (dr < -rows / 2) dr += rows; + if (dc > cols / 2) dc -= cols; + if (dc < -cols / 2) dc += cols; + + // Predict next position + int newRow = (currentPosition.getRow() + dr + rows) % rows; + int newCol = (currentPosition.getCol() + dc + cols) % cols; + + return new Position(newRow, newCol); + } +} diff --git a/cmd/acb-evolver/internal/db/seeds/random_main.py.txt b/cmd/acb-evolver/internal/db/seeds/random_main.py.txt new file mode 100644 index 0000000..7325ec7 --- /dev/null +++ b/cmd/acb-evolver/internal/db/seeds/random_main.py.txt @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +RandomBot - A bot that makes random valid moves. + +This is a reference implementation demonstrating the HTTP protocol +in Python. It validates HMAC signatures and returns random moves. +""" + +import hashlib +import hmac +import json +import os +import random +from http.server import HTTPServer, BaseHTTPRequestHandler + + +class GameState: + """Represents the fog-filtered state visible to this bot.""" + + def __init__(self, data: dict): + self.match_id = data["match_id"] + self.turn = data["turn"] + self.config = data["config"] + self.you_id = data["you"]["id"] + self.you_energy = data["you"]["energy"] + self.you_score = data["you"]["score"] + self.bots = data["bots"] + self.energy = data.get("energy", []) + self.cores = data.get("cores", []) + self.walls = data.get("walls", []) + self.dead = data.get("dead", []) + + +class RandomBotHandler(BaseHTTPRequestHandler): + """HTTP request handler for RandomBot.""" + + secret: str = "" + + def log_message(self, format, *args): + """Suppress default logging.""" + pass + + def send_json_response(self, status: int, data: dict, match_id: str = "", turn: int = 0): + """Send a JSON response with HMAC signature.""" + body = json.dumps(data).encode("utf-8") + + # Sign response + sig = self.sign_response(body, match_id, turn) + + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("X-ACB-Signature", sig) + self.end_headers() + self.wfile.write(body) + + def sign_response(self, body: bytes, match_id: str, turn: int) -> str: + """Generate HMAC signature for response.""" + body_hash = hashlib.sha256(body).hexdigest() + signing_string = f"{match_id}.{turn}.{body_hash}" + sig = hmac.new( + self.secret.encode("utf-8"), + signing_string.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + return sig + + def verify_signature(self, body: bytes, match_id: str, turn: str, + timestamp: str, signature: str) -> bool: + """Verify HMAC signature of incoming request.""" + body_hash = hashlib.sha256(body).hexdigest() + signing_string = f"{match_id}.{turn}.{timestamp}.{body_hash}" + expected_sig = hmac.new( + self.secret.encode("utf-8"), + signing_string.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(signature, expected_sig) + + def do_GET(self): + """Handle GET requests (health check).""" + if self.path == "/health": + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"OK") + else: + self.send_error(404, "Not Found") + + def do_POST(self): + """Handle POST requests (turn).""" + if self.path != "/turn": + self.send_error(404, "Not Found") + return + + # Read body + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + + # Get auth headers + match_id = self.headers.get("X-ACB-Match-Id", "") + turn_str = self.headers.get("X-ACB-Turn", "0") + timestamp = self.headers.get("X-ACB-Timestamp", "") + signature = self.headers.get("X-ACB-Signature", "") + + if not signature: + self.send_error(401, "Missing signature") + return + + # Verify signature + if not self.verify_signature(body, match_id, turn_str, timestamp, signature): + self.send_error(401, "Invalid signature") + return + + # Parse game state + try: + data = json.loads(body) + state = GameState(data) + except (json.JSONDecodeError, KeyError) as e: + self.send_error(400, f"Invalid game state: {e}") + return + + # Compute random moves + moves = self.compute_moves(state) + turn = int(turn_str) + + # Send response + self.send_json_response(200, {"moves": moves}, match_id, turn) + + def compute_moves(self, state: GameState) -> list: + """Compute random moves for all owned bots.""" + moves = [] + directions = ["N", "E", "S", "W"] + + for bot in state.bots: + if bot["owner"] == state.you_id: + # 50% chance to move, 50% chance to stay still + if random.random() < 0.5: + direction = random.choice(directions) + moves.append({ + "position": bot["position"], + "direction": direction + }) + + return moves + + +def main(): + port = int(os.environ.get("BOT_PORT", "8081")) + secret = os.environ.get("BOT_SECRET", "") + + if not secret: + print("ERROR: BOT_SECRET environment variable is required") + exit(1) + + RandomBotHandler.secret = secret + + server = HTTPServer(("", port), RandomBotHandler) + print(f"RandomBot starting on port {port}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/cmd/acb-evolver/internal/db/seeds/rusher_strategy.rs.txt b/cmd/acb-evolver/internal/db/seeds/rusher_strategy.rs.txt new file mode 100644 index 0000000..a745290 --- /dev/null +++ b/cmd/acb-evolver/internal/db/seeds/rusher_strategy.rs.txt @@ -0,0 +1,192 @@ +//! RusherBot strategy: aggressive core-rushing behavior. +//! +//! This strategy identifies and rushes the nearest enemy core as fast as possible. +//! Bots use BFS to find paths to enemy cores, ignoring energy and enemy bots +//! unless they block the path. + +use crate::game::{Direction, GameConfig, GameState, Move, Position, VisibleBot, VisibleCore}; +use std::collections::{HashMap, HashSet, VecDeque}; + +/// RusherStrategy implements aggressive core-rushing behavior. +pub struct RusherStrategy { + /// Known enemy core positions (discovered during gameplay) + known_enemy_cores: HashSet, +} + +impl RusherStrategy { + pub fn new() -> Self { + Self { + known_enemy_cores: HashSet::new(), + } + } + + /// Compute moves for all owned bots + pub fn compute_moves(&mut self, state: &GameState) -> Vec { + let my_id = state.you.id; + let config = &state.config; + + // Update known enemy cores + self.update_known_cores(state, my_id); + + // Separate my bots from enemies + let (my_bots, enemy_bots): (Vec<_>, Vec<_>) = + state.bots.iter().partition(|b| b.owner == my_id); + + if my_bots.is_empty() { + return vec![]; + } + + // Build position lookup for enemies + let enemy_positions: HashSet = + enemy_bots.iter().map(|b| b.position).collect(); + + // Build wall lookup + let walls: HashSet = state.walls.iter().copied().collect(); + + // Find target cores to rush + let targets = self.get_rush_targets(state, my_id); + + // Assign each bot to the nearest target + let mut moves = Vec::with_capacity(my_bots.len()); + let mut assigned_targets: HashSet = HashSet::new(); + + for bot in &my_bots { + if let Some((dir, _)) = self.find_best_move( + bot.position, + &targets, + &enemy_positions, + &walls, + &assigned_targets, + config, + ) { + // Mark target as assigned to avoid duplicates + if let Some(target) = self.find_target_for_bot(bot.position, &targets, config) { + assigned_targets.insert(target); + } + moves.push(Move { + position: bot.position, + direction: dir, + }); + } + } + + moves + } + + /// Update known enemy cores from visible state + fn update_known_cores(&mut self, state: &GameState, my_id: u32) { + for core in &state.cores { + if core.owner != my_id { + self.known_enemy_cores.insert(core.position); + } + } + } + + /// Get list of cores to rush (enemy cores first, then explore) + fn get_rush_targets(&self, state: &GameState, my_id: u32) -> Vec { + let mut targets: Vec = state + .cores + .iter() + .filter(|c| c.owner != my_id && c.active) + .map(|c| c.position) + .collect(); + + // If we know about enemy cores from previous turns, include them + for pos in &self.known_enemy_cores { + if !targets.contains(pos) { + targets.push(*pos); + } + } + + // If no enemy cores known, explore the map + if targets.is_empty() { + // Add exploration targets at grid edges + let rows = state.config.rows as i32; + let cols = state.config.cols as i32; + targets.push(Position { row: rows / 2, col: cols / 2 }); + targets.push(Position { row: 0, col: 0 }); + targets.push(Position { row: rows - 1, col: cols - 1 }); + } + + targets + } + + /// Find the best move for a bot using BFS toward targets + fn find_best_move( + &self, + start: Position, + targets: &[Position], + enemy_positions: &HashSet, + walls: &HashSet, + _assigned_targets: &HashSet, + config: &GameConfig, + ) -> Option<(Direction, Position)> { + let rows = config.rows as i32; + let cols = config.cols as i32; + + // BFS to find shortest path to any target + let mut visited: HashSet = HashSet::new(); + let mut queue: VecDeque<(Position, Option)> = VecDeque::new(); + + visited.insert(start); + queue.push_back((start, None)); + + while let Some((pos, first_dir)) = queue.pop_front() { + // Check if we've reached a target + if targets.contains(&pos) { + if let Some(dir) = first_dir { + return Some((dir, pos)); + } + } + + // Explore neighbors + for dir in Direction::all() { + let next = pos.move_toward(dir, rows, cols); + + if visited.contains(&next) || walls.contains(&next) { + continue; + } + + // Don't walk into enemy bots (but allow pathing near them) + if enemy_positions.contains(&next) { + continue; + } + + visited.insert(next); + queue.push_back((next, first_dir.or(Some(dir)))); + } + } + + // No path found - pick a random direction + for dir in Direction::all() { + let next = start.move_toward(dir, rows, cols); + if !walls.contains(&next) && !enemy_positions.contains(&next) { + return Some((dir, next)); + } + } + + None + } + + /// Find the nearest target for a bot + fn find_target_for_bot( + &self, + bot_pos: Position, + targets: &[Position], + config: &GameConfig, + ) -> Option { + let rows = config.rows as i32; + let cols = config.cols as i32; + + targets + .iter() + .min_by_key(|t| bot_pos.distance2(t, rows, cols)) + .copied() + } +} + +impl Default for RusherStrategy { + fn default() -> Self { + Self::new() + } +} diff --git a/cmd/acb-evolver/internal/db/seeds/swarm_strategy.ts.txt b/cmd/acb-evolver/internal/db/seeds/swarm_strategy.ts.txt new file mode 100644 index 0000000..930e28d --- /dev/null +++ b/cmd/acb-evolver/internal/db/seeds/swarm_strategy.ts.txt @@ -0,0 +1,228 @@ +/** + * SwarmBot strategy: formation-based combat with tight cohesion. + * + * Strategy: Keep units in tight formations, advance as a group toward enemies. + * - All bots maintain cohesion — no bot moves if it would be >3 tiles from the + * nearest friendly bot + * - The swarm moves as a unit toward the nearest enemy presence + * - BFS-based center-of-mass steering + * - Energy collection is incidental (pass over it during advance) + * - New spawns rally to the swarm before advancing + */ + +import { + GameState, + VisibleBot, + Position, + Move, + Direction, + GameConfig, + posKey, + posEquals, + moveToward, + distance2, + manhattanDistance, + ALL_DIRECTIONS, + buildPositionSet, +} from './game.js'; + +const COHESION_RADIUS = 3; // Maximum distance from nearest friendly +const COHESION_RADIUS2 = COHESION_RADIUS * COHESION_RADIUS; + +export class SwarmStrategy { + /** + * Compute moves for all owned bots + */ + computeMoves(state: GameState): Move[] { + const myId = state.you.id; + const config = state.config; + + // Separate my bots from enemies + const myBots: VisibleBot[] = []; + const enemyBots: VisibleBot[] = []; + for (const bot of state.bots) { + if (bot.owner === myId) { + myBots.push(bot); + } else { + enemyBots.push(bot); + } + } + + if (myBots.length === 0) { + return []; + } + + // Build wall lookup + const walls = buildPositionSet(state.walls); + + // Build enemy position lookup + const enemyPositions = new Map(); + for (const bot of enemyBots) { + enemyPositions.set(posKey(bot.position), bot); + } + + // Calculate swarm center (center of mass of my bots) + const swarmCenter = this.calculateCenter(myBots.map(b => b.position), config); + + // Calculate enemy center if any enemies visible + const enemyCenter = enemyBots.length > 0 + ? this.calculateCenter(enemyBots.map(b => b.position), config) + : null; + + // My bot positions for cohesion checks + const myBotPositions = new Set(myBots.map(b => posKey(b.position))); + + const moves: Move[] = []; + + for (const bot of myBots) { + const move = this.computeBotMove( + bot, + myBotPositions, + enemyPositions, + swarmCenter, + enemyCenter, + walls, + config + ); + if (move) { + moves.push(move); + } + } + + return moves; + } + + /** + * Calculate center of mass of positions + */ + private calculateCenter(positions: Position[], config: GameConfig): Position { + if (positions.length === 0) { + return { row: config.rows / 2, col: config.cols / 2 }; + } + + // Use circular mean for toroidal coordinates + let sumSinRow = 0, sumCosRow = 0; + let sumSinCol = 0, sumCosCol = 0; + + const rowScale = (2 * Math.PI) / config.rows; + const colScale = (2 * Math.PI) / config.cols; + + for (const pos of positions) { + sumSinRow += Math.sin(pos.row * rowScale); + sumCosRow += Math.cos(pos.row * rowScale); + sumSinCol += Math.sin(pos.col * colScale); + sumCosCol += Math.cos(pos.col * colScale); + } + + const avgRow = Math.atan2(sumSinRow / positions.length, sumCosRow / positions.length) / rowScale; + const avgCol = Math.atan2(sumSinCol / positions.length, sumCosCol / positions.length) / colScale; + + return { + row: ((avgRow % config.rows) + config.rows) % config.rows, + col: ((avgCol % config.cols) + config.cols) % config.cols, + }; + } + + /** + * Compute move for a single bot + */ + private computeBotMove( + bot: VisibleBot, + myBotPositions: Set, + enemyPositions: Map, + swarmCenter: Position, + enemyCenter: Position | null, + walls: Set, + config: GameConfig + ): Move | null { + const rows = config.rows; + const cols = config.cols; + + // Find direction that maintains cohesion while advancing toward enemy + let bestDir: Direction | null = null; + let bestScore = -Infinity; + + // Target is enemy center if visible, otherwise explore + const target = enemyCenter ?? { row: rows / 2, col: cols / 2 }; + + for (const dir of ALL_DIRECTIONS) { + const newPos = moveToward(bot.position, dir, rows, cols); + const newPosKey = posKey(newPos); + + // Can't move into walls or enemies + if (walls.has(newPosKey) || enemyPositions.has(newPosKey)) { + continue; + } + + // Check cohesion: must stay within COHESION_RADIUS of at least one friendly bot + if (!this.maintainsCohesion(newPos, bot.position, myBotPositions, rows, cols)) { + continue; + } + + // Score this move + let score = 0; + + // Prefer moving toward enemy center (or target) + const distToTarget = distance2(newPos, target, rows, cols); + const currentDistToTarget = distance2(bot.position, target, rows, cols); + score += (currentDistToTarget - distToTarget) * 10; // Reward getting closer + + // Prefer staying near swarm center + const distToSwarmCenter = distance2(newPos, swarmCenter, rows, cols); + score -= distToSwarmCenter * 0.5; // Penalize being far from swarm + + // Bonus for moving toward nearby enemies (engagement) + let nearestEnemyDist = Infinity; + for (const enemy of enemyPositions.values()) { + const dist = distance2(newPos, enemy.position, rows, cols); + nearestEnemyDist = Math.min(nearestEnemyDist, dist); + } + if (nearestEnemyDist < Infinity) { + // Bonus for being in attack range + if (nearestEnemyDist <= config.attack_radius2) { + score += 50; + } + } + + if (score > bestScore) { + bestScore = score; + bestDir = dir; + } + } + + if (bestDir) { + return { position: bot.position, direction: bestDir }; + } + + // If no good move found, try to stay put or move toward swarm + return null; + } + + /** + * Check if moving to newPos maintains cohesion with friendly bots + */ + private maintainsCohesion( + newPos: Position, + oldPos: Position, + myBotPositions: Set, + rows: number, + cols: number + ): boolean { + // Temporarily remove old position and add new position + const oldKey = posKey(oldPos); + + for (const botPosKey of myBotPositions) { + if (botPosKey === oldKey) continue; + + const [row, col] = botPosKey.split(',').map(Number); + const botPos = { row, col }; + + const dist2 = distance2(newPos, botPos, rows, cols); + if (dist2 <= COHESION_RADIUS2) { + return true; + } + } + + return false; + } +} diff --git a/cmd/acb-evolver/internal/db/validation_log.go b/cmd/acb-evolver/internal/db/validation_log.go new file mode 100644 index 0000000..6386873 --- /dev/null +++ b/cmd/acb-evolver/internal/db/validation_log.go @@ -0,0 +1,112 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +// ValidationLog records the outcome of one validation pipeline run. +// It is written after every candidate evaluation so pass rates can be +// computed per island and per language. +type ValidationLog struct { + ID int64 + Island string // one of IslandAlpha … IslandDelta + Language string // e.g. "go", "python" + Stage string // last stage attempted: "syntax", "schema", or "sandbox" + Passed bool // true when all stages up to (and including) Stage passed + ErrorText string // human-readable failure reason (empty on pass) + LLMOutput string // raw LLM response, for retry / learning + CreatedAt time.Time +} + +// RecordValidation inserts one ValidationLog row. +func (s *Store) RecordValidation(ctx context.Context, v *ValidationLog) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO validation_log (island, language, stage, passed, error_text, llm_output) + VALUES ($1, $2, $3, $4, $5, $6)`, + v.Island, v.Language, v.Stage, v.Passed, v.ErrorText, v.LLMOutput, + ) + if err != nil { + return fmt.Errorf("record validation: %w", err) + } + return nil +} + +// IslandPassRates returns the validation pass rate (0.0–1.0) keyed by island +// name. Islands with no records are omitted from the result. +func (s *Store) IslandPassRates(ctx context.Context) (map[string]float64, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT island, + SUM(CASE WHEN passed THEN 1 ELSE 0 END)::float + / NULLIF(COUNT(*), 0) AS pass_rate + FROM validation_log + GROUP BY island + `) + if err != nil { + return nil, fmt.Errorf("island pass rates: %w", err) + } + defer rows.Close() + + result := make(map[string]float64) + for rows.Next() { + var island string + var rate float64 + if err := rows.Scan(&island, &rate); err != nil { + return nil, fmt.Errorf("scan pass rate: %w", err) + } + result[island] = rate + } + return result, rows.Err() +} + +// ValidationStats holds aggregate metrics for one island. +type ValidationStats struct { + Island string + Total int + Passed int + PassRate float64 + ByStage map[string]int // count of runs that FAILED at each stage +} + +// IslandValidationStats returns per-island validation statistics including +// breakdown by failure stage. Islands with no rows are not returned. +func (s *Store) IslandValidationStats(ctx context.Context) ([]ValidationStats, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT island, + COUNT(*) AS total, + SUM(CASE WHEN passed THEN 1 ELSE 0 END) AS passed_count, + SUM(CASE WHEN NOT passed AND stage = 'syntax' THEN 1 ELSE 0 END) AS fail_syntax, + SUM(CASE WHEN NOT passed AND stage = 'schema' THEN 1 ELSE 0 END) AS fail_schema, + SUM(CASE WHEN NOT passed AND stage = 'sandbox' THEN 1 ELSE 0 END) AS fail_sandbox + FROM validation_log + GROUP BY island + ORDER BY island + `) + if err != nil { + return nil, fmt.Errorf("validation stats: %w", err) + } + defer rows.Close() + + var out []ValidationStats + for rows.Next() { + var v ValidationStats + var failSyntax, failSchema, failSandbox int + if err := rows.Scan( + &v.Island, &v.Total, &v.Passed, + &failSyntax, &failSchema, &failSandbox, + ); err != nil { + return nil, fmt.Errorf("scan validation stats: %w", err) + } + if v.Total > 0 { + v.PassRate = float64(v.Passed) / float64(v.Total) + } + v.ByStage = map[string]int{ + "syntax": failSyntax, + "schema": failSchema, + "sandbox": failSandbox, + } + out = append(out, v) + } + return out, rows.Err() +} diff --git a/cmd/acb-evolver/internal/mapelites/grid.go b/cmd/acb-evolver/internal/mapelites/grid.go new file mode 100644 index 0000000..50ede15 --- /dev/null +++ b/cmd/acb-evolver/internal/mapelites/grid.go @@ -0,0 +1,115 @@ +// Package mapelites implements a 2-D MAP-Elites behavior grid for diversity +// maintenance in the evolution pipeline. +// +// The two behavior dimensions are: +// +// X axis – aggression (0.0 = pacifist … 1.0 = full aggressor) +// Y axis – economy (0.0 = ignores energy … 1.0 = perfect economy) +// +// Each cell in the Size×Size grid holds the ID and fitness of the single best +// program discovered in that behavioral niche. +package mapelites + +import "math" + +// Grid is a 2-D MAP-Elites behavior grid. +type Grid struct { + size int + cells [][]Cell +} + +// Cell is a single niche in the grid. +type Cell struct { + ProgramID int64 + Fitness float64 + Occupied bool +} + +// Placement records which grid cell a program was placed into. +type Placement struct { + X, Y int +} + +// New creates an empty Grid with the given side length. +func New(size int) *Grid { + cells := make([][]Cell, size) + for i := range cells { + cells[i] = make([]Cell, size) + } + return &Grid{size: size, cells: cells} +} + +// BehaviorToCell converts continuous behavior values (each in [0, 1]) to +// discrete grid coordinates clamped to [0, size-1]. +func (g *Grid) BehaviorToCell(aggression, economy float64) (x, y int) { + x = int(math.Min(math.Floor(aggression*float64(g.size)), float64(g.size-1))) + y = int(math.Min(math.Floor(economy*float64(g.size)), float64(g.size-1))) + return +} + +// TryPlace attempts to place a program in the cell determined by its behavior +// vector. The cell is updated only when it is empty or the new program has +// strictly higher fitness than the incumbent. +// Returns the target cell coordinates and whether the cell was updated. +func (g *Grid) TryPlace(id int64, fitness, aggression, economy float64) (Placement, bool) { + x, y := g.BehaviorToCell(aggression, economy) + cell := &g.cells[x][y] + + if !cell.Occupied || fitness > cell.Fitness { + *cell = Cell{ProgramID: id, Fitness: fitness, Occupied: true} + return Placement{X: x, Y: y}, true + } + return Placement{X: x, Y: y}, false +} + +// Get returns the cell at grid coordinates (x, y). +func (g *Grid) Get(x, y int) Cell { + return g.cells[x][y] +} + +// Size returns the side length of the grid. +func (g *Grid) Size() int { + return g.size +} + +// OccupiedCount returns the number of filled cells. +func (g *Grid) OccupiedCount() int { + n := 0 + for _, row := range g.cells { + for _, c := range row { + if c.Occupied { + n++ + } + } + } + return n +} + +// Elite returns the cell with the highest fitness in the grid. +// Returns (zero Cell, false) when the grid is empty. +func (g *Grid) Elite() (Cell, bool) { + var best Cell + found := false + for _, row := range g.cells { + for _, c := range row { + if c.Occupied && (!found || c.Fitness > best.Fitness) { + best = c + found = true + } + } + } + return best, found +} + +// AllElites returns a flat slice of every occupied cell. +func (g *Grid) AllElites() []Cell { + var out []Cell + for _, row := range g.cells { + for _, c := range row { + if c.Occupied { + out = append(out, c) + } + } + } + return out +} diff --git a/cmd/acb-evolver/internal/mapelites/grid_test.go b/cmd/acb-evolver/internal/mapelites/grid_test.go new file mode 100644 index 0000000..4bbcbaf --- /dev/null +++ b/cmd/acb-evolver/internal/mapelites/grid_test.go @@ -0,0 +1,170 @@ +package mapelites + +import "testing" + +func TestBehaviorToCell(t *testing.T) { + g := New(10) + + cases := []struct { + agg, eco float64 + wantX, wantY int + }{ + {0.0, 0.0, 0, 0}, + {1.0, 1.0, 9, 9}, + {0.5, 0.5, 5, 5}, + {0.15, 0.85, 1, 8}, + {0.99, 0.01, 9, 0}, + {0.09, 0.09, 0, 0}, + {0.1, 0.9, 1, 9}, + } + + for _, tc := range cases { + x, y := g.BehaviorToCell(tc.agg, tc.eco) + if x != tc.wantX || y != tc.wantY { + t.Errorf("BehaviorToCell(%.2f, %.2f) = (%d, %d), want (%d, %d)", + tc.agg, tc.eco, x, y, tc.wantX, tc.wantY) + } + } +} + +func TestTryPlace_EmptyCell(t *testing.T) { + g := New(10) + p, placed := g.TryPlace(1, 10.0, 0.1, 0.9) + if !placed { + t.Fatal("expected placement into empty cell") + } + if p.X != 1 || p.Y != 9 { + t.Errorf("expected cell (1,9), got (%d,%d)", p.X, p.Y) + } + cell := g.Get(1, 9) + if !cell.Occupied || cell.ProgramID != 1 || cell.Fitness != 10.0 { + t.Errorf("unexpected cell state: %+v", cell) + } +} + +func TestTryPlace_LowerFitnessDoesNotReplace(t *testing.T) { + g := New(10) + g.TryPlace(1, 10.0, 0.5, 0.5) + + _, placed := g.TryPlace(2, 5.0, 0.5, 0.5) + if placed { + t.Fatal("lower fitness should not replace incumbent") + } + if g.Get(5, 5).ProgramID != 1 { + t.Error("incumbent program 1 should still hold the cell") + } +} + +func TestTryPlace_HigherFitnessReplaces(t *testing.T) { + g := New(10) + g.TryPlace(1, 10.0, 0.5, 0.5) + + _, placed := g.TryPlace(2, 20.0, 0.5, 0.5) + if !placed { + t.Fatal("higher fitness should replace incumbent") + } + cell := g.Get(5, 5) + if cell.ProgramID != 2 || cell.Fitness != 20.0 { + t.Errorf("expected program 2 with fitness 20, got %+v", cell) + } +} + +func TestTryPlace_EqualFitnessDoesNotReplace(t *testing.T) { + g := New(10) + g.TryPlace(1, 10.0, 0.5, 0.5) + _, placed := g.TryPlace(2, 10.0, 0.5, 0.5) + if placed { + t.Fatal("equal fitness should not replace incumbent") + } +} + +func TestOccupiedCount(t *testing.T) { + g := New(10) + if g.OccupiedCount() != 0 { + t.Error("new grid should have 0 occupied cells") + } + g.TryPlace(1, 1.0, 0.1, 0.1) + g.TryPlace(2, 1.0, 0.9, 0.9) + g.TryPlace(3, 1.0, 0.5, 0.5) + if g.OccupiedCount() != 3 { + t.Errorf("expected 3 occupied cells, got %d", g.OccupiedCount()) + } + // Same cell should not increase count + g.TryPlace(4, 99.0, 0.5, 0.5) + if g.OccupiedCount() != 3 { + t.Errorf("expected still 3 occupied cells after same-cell update, got %d", g.OccupiedCount()) + } +} + +func TestElite_EmptyGrid(t *testing.T) { + g := New(10) + _, found := g.Elite() + if found { + t.Fatal("empty grid should have no elite") + } +} + +func TestElite(t *testing.T) { + g := New(10) + g.TryPlace(1, 5.0, 0.1, 0.1) + g.TryPlace(2, 15.0, 0.9, 0.9) + g.TryPlace(3, 10.0, 0.5, 0.5) + + elite, found := g.Elite() + if !found { + t.Fatal("expected an elite in non-empty grid") + } + if elite.ProgramID != 2 || elite.Fitness != 15.0 { + t.Errorf("expected elite program 2 (fitness 15), got %+v", elite) + } +} + +func TestAllElites(t *testing.T) { + g := New(10) + if len(g.AllElites()) != 0 { + t.Error("empty grid should return no elites") + } + + g.TryPlace(1, 1.0, 0.0, 0.0) + g.TryPlace(2, 2.0, 0.5, 0.5) + g.TryPlace(3, 3.0, 1.0, 1.0) + + elites := g.AllElites() + if len(elites) != 3 { + t.Errorf("expected 3 elites, got %d", len(elites)) + } +} + +func TestSeedBehaviorVectors(t *testing.T) { + // Verify that the 6 seed bots land in distinct grid cells on a 10x10 grid. + g := New(10) + + bots := []struct { + id int64 + name string + aggression float64 + economy float64 + }{ + {1, "gatherer", 0.1, 0.9}, + {2, "guardian", 0.2, 0.6}, + {3, "rusher", 0.9, 0.2}, + {4, "swarm", 0.6, 0.5}, + {5, "hunter", 0.7, 0.3}, + {6, "random", 0.3, 0.4}, + } + + placed := 0 + for _, b := range bots { + _, ok := g.TryPlace(b.id, 1.0, b.aggression, b.economy) + if ok { + placed++ + } + } + + if placed != 6 { + t.Errorf("expected all 6 seed bots in distinct cells, but only %d placed", placed) + } + if g.OccupiedCount() != 6 { + t.Errorf("expected 6 occupied cells, got %d", g.OccupiedCount()) + } +} diff --git a/cmd/acb-evolver/internal/validator/sandbox.go b/cmd/acb-evolver/internal/validator/sandbox.go new file mode 100644 index 0000000..ef56c38 --- /dev/null +++ b/cmd/acb-evolver/internal/validator/sandbox.go @@ -0,0 +1,420 @@ +package validator + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "time" +) + +const ( + // smokeMatchID and smokeSecret are fixed values used only during smoke tests. + smokeMatchID = "smoke-test-match" + smokeSecret = "smoke-test-secret-for-validation" + smokeBotID = "b_smoketest" + + // healthPollInterval is how often we ping /health while waiting for startup. + healthPollInterval = 200 * time.Millisecond + // healthTimeout is how long we wait for the bot to become healthy. + healthStartupTimeout = 20 * time.Second +) + +// RunSmokeTest builds and starts the bot in an isolated sandbox, then sends +// cfg.SmokeRequests test /turn requests and verifies all responses are valid. +// +// When nsjail is found in PATH (and cfg.UseNsjail is true), it wraps the bot +// process for CPU/memory resource limits. Otherwise it runs the bot directly +// with a context deadline. +func RunSmokeTest(ctx context.Context, code, language string, cfg Config) error { + ctx, cancel := context.WithTimeout(ctx, cfg.SandboxTimeout) + defer cancel() + + dir, err := os.MkdirTemp("", "acb-sandbox-*") + if err != nil { + return fmt.Errorf("mkdirtemp: %w", err) + } + defer os.RemoveAll(dir) + + // Build / prepare the bot. + execPath, execArgs, err := buildBot(ctx, code, language, dir) + if err != nil { + return fmt.Errorf("build: %w", err) + } + + // Allocate a free port so the bot can be reached from this process. + port, err := freePort() + if err != nil { + return fmt.Errorf("allocate port: %w", err) + } + addr := fmt.Sprintf("127.0.0.1:%d", port) + + // Compose the bot's environment. + env := append(os.Environ(), + fmt.Sprintf("BOT_PORT=%d", port), + "BOT_SECRET="+smokeSecret, + ) + + // Construct the run command, optionally wrapped in nsjail. + cmd := makeBotCmd(ctx, execPath, execArgs, dir, env, cfg) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start bot: %w", err) + } + defer func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + }() + + // Wait for the /health endpoint to respond before sending turn requests. + if err := waitForHealth(ctx, addr, healthStartupTimeout); err != nil { + diag := truncate(stderr.String(), 256) + return fmt.Errorf("health startup timeout: %w (stderr: %s)", err, diag) + } + + // Fire cfg.SmokeRequests test requests. + client := &http.Client{Timeout: 5 * time.Second} + for i := 1; i <= cfg.SmokeRequests; i++ { + if err := sendTurnRequest(ctx, client, addr, i); err != nil { + return fmt.Errorf("smoke request %d/%d failed: %w", i, cfg.SmokeRequests, err) + } + } + return nil +} + +// makeBotCmd returns an *exec.Cmd that runs the bot, optionally wrapped in +// nsjail when it is available and cfg.UseNsjail is true. +func makeBotCmd(ctx context.Context, execPath string, execArgs []string, dir string, env []string, cfg Config) *exec.Cmd { + if cfg.UseNsjail { + if nsjailBin, err := exec.LookPath(cfg.NsjailPath); err == nil { + return buildNsjailCmd(ctx, nsjailBin, execPath, execArgs, dir, env) + } + } + // Plain exec fallback. + cmd := exec.CommandContext(ctx, execPath, execArgs...) + cmd.Env = env + cmd.Dir = dir + return cmd +} + +// buildNsjailCmd wraps execPath in nsjail for CPU/memory resource limiting. +// Network isolation is not applied so the bot can bind its HTTP port and +// receive requests from the test loop running in the same network namespace. +func buildNsjailCmd(ctx context.Context, nsjailBin, execPath string, execArgs []string, dir string, env []string) *exec.Cmd { + args := []string{ + "--mode", "o", // single-shot: run one command then exit + "--time_limit", "30", // 30-second wall-clock limit + "--rlimit_as", "512", // 512 MiB virtual address space + "--rlimit_cpu", "15", // 15 CPU seconds + "--rlimit_nofile", "64", + "--chdir", dir, + "--bindmount", dir, // bot workspace, read-write + } + + // Read-only bind-mounts for language runtimes and system libraries. + for _, p := range []string{"/bin", "/usr", "/lib", "/lib64", "/etc/alternatives", "/proc", "/dev"} { + if _, err := os.Stat(p); err == nil { + args = append(args, "--bindmount_ro", p) + } + } + args = append(args, "--tmpfsmount", "/tmp") + args = append(args, "--") + args = append(args, execPath) + args = append(args, execArgs...) + + cmd := exec.CommandContext(ctx, nsjailBin, args...) + cmd.Env = env + cmd.Dir = dir + return cmd +} + +// buildBot writes the bot source to dir, compiles it where necessary, and +// returns the executable path plus any arguments needed to run it. +func buildBot(ctx context.Context, code, language, dir string) (string, []string, error) { + switch language { + case "go": + return buildGo(ctx, code, dir) + case "python": + src := filepath.Join(dir, "bot.py") + if err := os.WriteFile(src, []byte(code), 0o600); err != nil { + return "", nil, err + } + return "python3", []string{src}, nil + + case "rust": + return buildRust(ctx, code, dir) + + case "typescript": + return buildTypeScript(ctx, code, dir) + + case "java": + return buildJava(ctx, code, dir) + + case "php": + src := filepath.Join(dir, "bot.php") + if err := os.WriteFile(src, []byte(code), 0o600); err != nil { + return "", nil, err + } + return "php", []string{src}, nil + + default: + return "", nil, fmt.Errorf("unsupported language: %s", language) + } +} + +func buildGo(ctx context.Context, code, dir string) (string, []string, error) { + if err := os.WriteFile(filepath.Join(dir, "bot.go"), []byte(code), 0o600); err != nil { + return "", nil, err + } + // Minimal go.mod so `go build` works outside a workspace. + gomod := "module bot\n\ngo 1.21\n" + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o600); err != nil { + return "", nil, err + } + binPath := filepath.Join(dir, "bot") + cmd := exec.CommandContext(ctx, "go", "build", "-o", binPath, ".") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + return "", nil, fmt.Errorf("go build: %s", truncate(string(out), 512)) + } + return binPath, nil, nil +} + +func buildRust(ctx context.Context, code, dir string) (string, []string, error) { + src := filepath.Join(dir, "main.rs") + if err := os.WriteFile(src, []byte(code), 0o600); err != nil { + return "", nil, err + } + binPath := filepath.Join(dir, "bot") + cmd := exec.CommandContext(ctx, "rustc", "--edition", "2021", src, "-o", binPath) + if out, err := cmd.CombinedOutput(); err != nil { + return "", nil, fmt.Errorf("rustc: %s", truncate(string(out), 512)) + } + return binPath, nil, nil +} + +func buildTypeScript(ctx context.Context, code, dir string) (string, []string, error) { + if err := os.WriteFile(filepath.Join(dir, "bot.ts"), []byte(code), 0o600); err != nil { + return "", nil, err + } + tsconfig := `{"compilerOptions":{"target":"ES2020","module":"commonjs","outDir":"./"},"files":["bot.ts"]}` + if err := os.WriteFile(filepath.Join(dir, "tsconfig.json"), []byte(tsconfig), 0o600); err != nil { + return "", nil, err + } + cmd := exec.CommandContext(ctx, "tsc", "--project", filepath.Join(dir, "tsconfig.json")) + if out, err := cmd.CombinedOutput(); err != nil { + return "", nil, fmt.Errorf("tsc: %s", truncate(string(out), 512)) + } + return "node", []string{filepath.Join(dir, "bot.js")}, nil +} + +func buildJava(ctx context.Context, code, dir string) (string, []string, error) { + className := extractJavaPublicClass(code) + if className == "" { + className = "Bot" + } + src := filepath.Join(dir, className+".java") + if err := os.WriteFile(src, []byte(code), 0o600); err != nil { + return "", nil, err + } + cmd := exec.CommandContext(ctx, "javac", src) + if out, err := cmd.CombinedOutput(); err != nil { + return "", nil, fmt.Errorf("javac: %s", truncate(string(out), 512)) + } + return "java", []string{"-cp", dir, className}, nil +} + +// freePort returns an unused TCP port on localhost. +func freePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + return port, nil +} + +// waitForHealth polls GET /health until it returns 200 or timeout elapses. +func waitForHealth(ctx context.Context, addr string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + client := &http.Client{Timeout: 500 * time.Millisecond} + for time.Now().Before(deadline) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr+"/health", nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(healthPollInterval): + } + } + return fmt.Errorf("bot did not become healthy within %s", timeout) +} + +// sendTurnRequest sends one POST /turn request to the bot and validates the +// JSON response. +func sendTurnRequest(ctx context.Context, client *http.Client, addr string, turn int) error { + state := makeTestState(turn) + body, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("marshal state: %w", err) + } + + sig := signSmokeRequest(smokeSecret, smokeMatchID, turn, body) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + "http://"+addr+"/turn", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-ACB-Match-Id", smokeMatchID) + req.Header.Set("X-ACB-Turn", strconv.Itoa(turn)) + req.Header.Set("X-ACB-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) + req.Header.Set("X-ACB-Bot-Id", smokeBotID) + req.Header.Set("X-ACB-Signature", sig) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bot returned HTTP %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + return validateMoveResponse(respBody) +} + +// signSmokeRequest computes the HMAC-SHA256 signature used by reference bot +// implementations. The signing string matches the format in bots/*/main.go: +// +// "{match_id}.{turn}.{sha256hex(body)}" +// +// Note: this format does NOT include a timestamp, matching the reference bots +// (bots/gatherer, bots/rusher, etc.) that LLM candidates are shown as templates. +func signSmokeRequest(secret, matchID string, turn int, body []byte) string { + bodyHash := sha256.Sum256(body) + msg := fmt.Sprintf("%s.%d.%s", matchID, turn, hex.EncodeToString(bodyHash[:])) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(msg)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// ── Test state types ────────────────────────────────────────────────────── + +type smokeState struct { + MatchID string `json:"match_id"` + Turn int `json:"turn"` + Config smokeConfig `json:"config"` + You smokePlayer `json:"you"` + Bots []smokeBot `json:"bots"` + Energy []smokePos `json:"energy"` + Cores []smokeCore `json:"cores"` + Walls []smokePos `json:"walls"` + Dead []smokeBot `json:"dead"` +} + +type smokeConfig struct { + Rows int `json:"rows"` + Cols int `json:"cols"` + MaxTurns int `json:"max_turns"` + VisionRadius2 int `json:"vision_radius2"` + AttackRadius2 int `json:"attack_radius2"` + SpawnCost int `json:"spawn_cost"` + EnergyInterval int `json:"energy_interval"` +} + +type smokePlayer struct { + ID int `json:"id"` + Energy int `json:"energy"` + Score int `json:"score"` +} + +type smokeBot struct { + Position smokePos `json:"position"` + Owner int `json:"owner"` +} + +type smokePos struct { + Row int `json:"row"` + Col int `json:"col"` +} + +type smokeCore struct { + Position smokePos `json:"position"` + Owner int `json:"owner"` + Active bool `json:"active"` +} + +// makeTestState returns a minimal, syntactically valid game state for smoke testing. +func makeTestState(turn int) smokeState { + return smokeState{ + MatchID: smokeMatchID, + Turn: turn, + Config: smokeConfig{ + Rows: 60, Cols: 60, + MaxTurns: 500, VisionRadius2: 49, + AttackRadius2: 1, SpawnCost: 3, + EnergyInterval: 10, + }, + You: smokePlayer{ID: 1, Energy: 10, Score: 0}, + Bots: []smokeBot{ + {Position: smokePos{Row: 10, Col: 10}, Owner: 1}, + {Position: smokePos{Row: 20, Col: 15}, Owner: 2}, + }, + Energy: []smokePos{ + {Row: 12, Col: 12}, + {Row: 5, Col: 30}, + }, + Cores: []smokeCore{ + {Position: smokePos{Row: 15, Col: 15}, Owner: 1, Active: true}, + }, + Walls: []smokePos{}, + Dead: []smokeBot{}, + } +} + +// moveResponse is the expected JSON structure of a /turn response. +type moveResponse struct { + Moves []json.RawMessage `json:"moves"` +} + +// validateMoveResponse checks the bot returned valid JSON with a "moves" array. +// An empty moves array is accepted (the bot may legally choose to idle). +func validateMoveResponse(body []byte) error { + var resp moveResponse + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("invalid JSON in /turn response: %w (body: %.200s)", err, body) + } + return nil +} diff --git a/cmd/acb-evolver/internal/validator/schema.go b/cmd/acb-evolver/internal/validator/schema.go new file mode 100644 index 0000000..debf870 --- /dev/null +++ b/cmd/acb-evolver/internal/validator/schema.go @@ -0,0 +1,78 @@ +package validator + +import ( + "fmt" + "regexp" +) + +// endpointSpec holds the regex patterns used to detect the two required +// HTTP endpoints in a bot's source code. +type endpointSpec struct { + // healthRe matches the /health endpoint route registration. + healthRe *regexp.Regexp + // turnRe matches the /turn endpoint route registration. + turnRe *regexp.Regexp +} + +// specs maps canonical language names to their detection patterns. +// The patterns are intentionally broad to accommodate the various HTTP +// frameworks an LLM might choose (net/http, Flask, Express, Actix, etc.). +var specs = map[string]endpointSpec{ + "go": { + // Matches string literals "/health" and "/turn" (double-quote or backtick). + healthRe: regexp.MustCompile("[\"`]/health[\"`]"), + turnRe: regexp.MustCompile("[\"`]/turn[\"`]"), + }, + "python": { + healthRe: regexp.MustCompile(`['"]/health['"]`), + turnRe: regexp.MustCompile(`['"]/turn['"]`), + }, + "rust": { + healthRe: regexp.MustCompile(`['"]/health['"]`), + turnRe: regexp.MustCompile(`['"]/turn['"]`), + }, + "typescript": { + healthRe: regexp.MustCompile(`['"]/health['"]`), + turnRe: regexp.MustCompile(`['"]/turn['"]`), + }, + "java": { + healthRe: regexp.MustCompile(`['"]/health['"]`), + turnRe: regexp.MustCompile(`['"]/turn['"]`), + }, + "php": { + healthRe: regexp.MustCompile(`['"]/health['"]`), + turnRe: regexp.MustCompile(`['"]/turn['"]`), + }, +} + +// movesRe detects whether the source references a JSON "moves" field, which +// is required in the POST /turn response. +var movesRe = regexp.MustCompile(`(?i)["']moves["']|\bmoves\b`) + +// CheckSchema performs static analysis on code to confirm it exposes the two +// required HTTP endpoints and returns a moves response: +// +// - GET /health → must return HTTP 200 +// - POST /turn → must accept JSON game state and return {"moves":[...]} +// +// The check is language-aware but uses lightweight regex matching — it does +// not perform full AST analysis. False negatives (a syntactically unusual +// but correct bot) are possible; they will be caught by the sandbox stage. +func CheckSchema(code, language string) error { + spec, ok := specs[language] + if !ok { + return fmt.Errorf("unsupported language: %s", language) + } + + if !spec.healthRe.MatchString(code) { + return fmt.Errorf("schema: /health endpoint not found — bot must implement GET /health") + } + if !spec.turnRe.MatchString(code) { + return fmt.Errorf("schema: /turn endpoint not found — bot must implement POST /turn") + } + if !movesRe.MatchString(code) { + return fmt.Errorf(`schema: no "moves" field detected — bot must return JSON {"moves":[...]}`) + } + + return nil +} diff --git a/cmd/acb-evolver/internal/validator/syntax.go b/cmd/acb-evolver/internal/validator/syntax.go new file mode 100644 index 0000000..059814f --- /dev/null +++ b/cmd/acb-evolver/internal/validator/syntax.go @@ -0,0 +1,175 @@ +package validator + +import ( + "context" + "fmt" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "regexp" + "time" +) + +// CheckSyntax validates the syntax of code for the given language. +// Returns nil when the code is syntactically valid. +// +// For Go it uses the stdlib go/parser (no subprocess). For other +// languages it shells out to the language's own syntax-checker binary; +// if the binary is not installed it falls back to a brace-balance check. +func CheckSyntax(ctx context.Context, code, language string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + switch language { + case "go": + return checkGoSyntax(code) + case "python": + return checkWithTempFile(ctx, code, "bot.py", + func(path string) *exec.Cmd { + return exec.CommandContext(ctx, "python3", "-m", "py_compile", path) + }) + case "rust": + return checkRustSyntax(ctx, code) + case "typescript": + return checkTSSyntax(ctx, code) + case "java": + return checkJavaSyntax(ctx, code) + case "php": + return checkWithTempFile(ctx, code, "bot.php", + func(path string) *exec.Cmd { + return exec.CommandContext(ctx, "php", "-l", path) + }) + default: + return fmt.Errorf("unsupported language: %s", language) + } +} + +// checkGoSyntax uses the stdlib go/parser to validate Go source. +// This is fast, dependency-free, and catches all parse errors. +func checkGoSyntax(code string) error { + fset := token.NewFileSet() + if _, err := parser.ParseFile(fset, "bot.go", code, 0); err != nil { + return fmt.Errorf("go syntax: %w", err) + } + return nil +} + +// checkRustSyntax tries rustfmt --check first, then falls back to brace balance. +func checkRustSyntax(ctx context.Context, code string) error { + if _, err := exec.LookPath("rustfmt"); err == nil { + return checkWithTempFile(ctx, code, "bot.rs", + func(path string) *exec.Cmd { + return exec.CommandContext(ctx, "rustfmt", "--check", path) + }) + } + return checkBraceBalance(code, "rust") +} + +// checkTSSyntax runs tsc --noEmit when available, then falls back to brace balance. +func checkTSSyntax(ctx context.Context, code string) error { + if _, err := exec.LookPath("tsc"); err != nil { + return checkBraceBalance(code, "typescript") + } + + dir, err := os.MkdirTemp("", "acb-syntax-ts-*") + if err != nil { + return fmt.Errorf("mkdirtemp: %w", err) + } + defer os.RemoveAll(dir) + + if err := os.WriteFile(filepath.Join(dir, "bot.ts"), []byte(code), 0o600); err != nil { + return fmt.Errorf("write temp file: %w", err) + } + // Minimal tsconfig so tsc accepts a single file without a project. + tsconfig := `{"compilerOptions":{"target":"ES2020","module":"commonjs","strict":false,"noEmit":true},"files":["bot.ts"]}` + if err := os.WriteFile(filepath.Join(dir, "tsconfig.json"), []byte(tsconfig), 0o600); err != nil { + return fmt.Errorf("write tsconfig: %w", err) + } + + cmd := exec.CommandContext(ctx, "tsc", "--project", filepath.Join(dir, "tsconfig.json")) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("typescript syntax: %s", truncate(string(out), 512)) + } + return nil +} + +// checkJavaSyntax compiles with javac (syntax pass only; output discarded). +func checkJavaSyntax(ctx context.Context, code string) error { + className := extractJavaPublicClass(code) + if className == "" { + className = "Bot" + } + return checkWithTempFile(ctx, code, className+".java", + func(path string) *exec.Cmd { + // -Xlint:none suppresses lint warnings so only errors appear. + return exec.CommandContext(ctx, "javac", "-Xlint:none", path) + }) +} + +// checkWithTempFile writes code to a temp directory as filename, then runs +// the command returned by cmdFn(filePath) and returns its error, if any. +func checkWithTempFile(ctx context.Context, code, filename string, cmdFn func(string) *exec.Cmd) error { + dir, err := os.MkdirTemp("", "acb-syntax-*") + if err != nil { + return fmt.Errorf("mkdirtemp: %w", err) + } + defer os.RemoveAll(dir) + + path := filepath.Join(dir, filename) + if err := os.WriteFile(path, []byte(code), 0o600); err != nil { + return fmt.Errorf("write temp file: %w", err) + } + + cmd := cmdFn(path) + out, err := cmd.CombinedOutput() + if err != nil { + msg := string(out) + if msg == "" { + return fmt.Errorf("syntax check failed: %w", err) + } + return fmt.Errorf("%s", truncate(msg, 512)) + } + return nil +} + +// extractJavaPublicClass returns the name of the first public class in src. +var javaPublicClassRe = regexp.MustCompile(`(?m)^\s*public\s+class\s+(\w+)`) + +func extractJavaPublicClass(src string) string { + m := javaPublicClassRe.FindStringSubmatch(src) + if len(m) < 2 { + return "" + } + return m[1] +} + +// checkBraceBalance is a last-resort fallback that verifies { } are balanced. +func checkBraceBalance(code, lang string) error { + depth := 0 + for _, ch := range code { + switch ch { + case '{': + depth++ + case '}': + depth-- + if depth < 0 { + return fmt.Errorf("%s syntax: unexpected '}'", lang) + } + } + } + if depth != 0 { + return fmt.Errorf("%s syntax: unmatched '{' (depth %d at EOF)", lang, depth) + } + return nil +} + +// truncate limits s to at most n runes, appending "…" when truncated. +func truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "…" +} diff --git a/cmd/acb-evolver/internal/validator/validator.go b/cmd/acb-evolver/internal/validator/validator.go new file mode 100644 index 0000000..f2c3f92 --- /dev/null +++ b/cmd/acb-evolver/internal/validator/validator.go @@ -0,0 +1,139 @@ +// Package validator implements a three-stage validation pipeline for +// LLM-generated bot candidates: +// +// 1. Syntax — parse the generated code for the target language +// 2. Schema — verify the bot exposes the required HTTP endpoints +// 3. Sandbox — run the bot in a nsjail container, send 5 test /turn +// requests, and verify valid JSON responses +// +// The pipeline is fail-fast: if any stage fails, subsequent stages are +// skipped. The raw LLM output is preserved in Report so the evolution +// loop can embed it in retry prompts. +package validator + +import ( + "context" + "time" +) + +// Stage identifies a validation pipeline stage. +type Stage string + +const ( + StageSyntax Stage = "syntax" + StageSchema Stage = "schema" + StageSandbox Stage = "sandbox" +) + +// StageResult holds the outcome of one pipeline stage. +type StageResult struct { + Stage Stage + Passed bool + Error string // non-empty on failure + Duration time.Duration // wall-clock time for the stage +} + +// Report is the complete outcome of a validation run. It is returned +// even when a stage fails so callers can log the partial results. +type Report struct { + Language string + Stages []StageResult + Passed bool // true only when all three stages pass + LLMOutput string // raw LLM response; preserved for retry / learning +} + +// LastStage returns the name of the last stage that was executed (whether +// it passed or not). Returns "" when no stages ran. +func (r *Report) LastStage() Stage { + if len(r.Stages) == 0 { + return "" + } + return r.Stages[len(r.Stages)-1].Stage +} + +// Config controls pipeline behaviour. +type Config struct { + // SyntaxTimeout caps the external process used for syntax checking. + SyntaxTimeout time.Duration + // SandboxTimeout caps the entire sandbox smoke test (build + run + requests). + SandboxTimeout time.Duration + // SmokeRequests is the number of /turn requests sent during the smoke test. + SmokeRequests int + // UseNsjail enables nsjail-based process isolation during the smoke test. + // Falls back to plain exec when nsjail is not found in PATH. + UseNsjail bool + // NsjailPath overrides the nsjail binary name / path (default "nsjail"). + NsjailPath string +} + +// DefaultConfig returns a Config with production-ready defaults. +func DefaultConfig() Config { + return Config{ + SyntaxTimeout: 15 * time.Second, + SandboxTimeout: 60 * time.Second, + SmokeRequests: 5, + UseNsjail: true, + NsjailPath: "nsjail", + } +} + +// Validate runs the full three-stage pipeline for the given bot code. +// llmOutput is the raw text from which code was extracted; it is stored +// in the report for retry or learning. +// +// The returned error is only non-nil for unexpected infrastructure failures +// (e.g. temp-dir creation). Validation failures are encoded in Report.Passed +// and the individual StageResult.Error fields. +func Validate(ctx context.Context, code, language, llmOutput string, cfg Config) (*Report, error) { + r := &Report{ + Language: language, + LLMOutput: llmOutput, + } + + type step struct { + name Stage + fn func(context.Context) error + } + + steps := []step{ + { + StageSyntax, + func(ctx context.Context) error { + return CheckSyntax(ctx, code, language, cfg.SyntaxTimeout) + }, + }, + { + StageSchema, + func(_ context.Context) error { + return CheckSchema(code, language) + }, + }, + { + StageSandbox, + func(ctx context.Context) error { + return RunSmokeTest(ctx, code, language, cfg) + }, + }, + } + + allPassed := true + for _, s := range steps { + t0 := time.Now() + err := s.fn(ctx) + sr := StageResult{ + Stage: s.name, + Passed: err == nil, + Duration: time.Since(t0), + } + if err != nil { + sr.Error = err.Error() + allPassed = false + } + r.Stages = append(r.Stages, sr) + if err != nil { + break // fail-fast: skip remaining stages + } + } + r.Passed = allPassed + return r, nil +} diff --git a/cmd/acb-evolver/internal/validator/validator_test.go b/cmd/acb-evolver/internal/validator/validator_test.go new file mode 100644 index 0000000..6c0d394 --- /dev/null +++ b/cmd/acb-evolver/internal/validator/validator_test.go @@ -0,0 +1,347 @@ +package validator + +import ( + "context" + "os/exec" + "testing" + "time" +) + +// ── Syntax tests ────────────────────────────────────────────────────────── + +func TestCheckSyntax_Go_Valid(t *testing.T) { + code := `package main + +import "net/http" + +func main() { http.ListenAndServe(":8080", nil) } +` + if err := CheckSyntax(context.Background(), code, "go", 5*time.Second); err != nil { + t.Fatalf("expected valid Go to pass, got: %v", err) + } +} + +func TestCheckSyntax_Go_Invalid(t *testing.T) { + code := `package main + +func main() { + x := // missing value +} +` + if err := CheckSyntax(context.Background(), code, "go", 5*time.Second); err == nil { + t.Fatal("expected invalid Go to fail, but got nil") + } +} + +func TestCheckSyntax_Go_UnmatchedBrace(t *testing.T) { + code := `package main + +func main() { + if true { +` + if err := CheckSyntax(context.Background(), code, "go", 5*time.Second); err == nil { + t.Fatal("expected unmatched brace to fail, but got nil") + } +} + +func TestCheckSyntax_UnsupportedLanguage(t *testing.T) { + if err := CheckSyntax(context.Background(), "code", "cobol", 5*time.Second); err == nil { + t.Fatal("expected unsupported language to fail") + } +} + +func TestCheckSyntax_Python_Valid(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not in PATH") + } + code := ` +import json, os +from http.server import HTTPServer, BaseHTTPRequestHandler + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == '/health': + self.send_response(200) + self.end_headers() + self.wfile.write(b'OK') + +if __name__ == '__main__': + HTTPServer(('', int(os.getenv('BOT_PORT', 8080))), Handler).serve_forever() +` + if err := CheckSyntax(context.Background(), code, "python", 10*time.Second); err != nil { + t.Fatalf("expected valid Python to pass, got: %v", err) + } +} + +func TestCheckSyntax_Python_Invalid(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not in PATH") + } + code := `def foo( + x = 1 + y = 2 # missing comma / closing paren +` + if err := CheckSyntax(context.Background(), code, "python", 10*time.Second); err == nil { + t.Fatal("expected invalid Python to fail") + } +} + +// ── Schema tests ────────────────────────────────────────────────────────── + +func TestCheckSchema_Go_Complete(t *testing.T) { + code := `package main + +import "net/http" + +func main() { + http.HandleFunc("/health", handleHealth) + http.HandleFunc("/turn", handleTurn) + http.ListenAndServe(":8080", nil) +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) } +func handleTurn(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(` + "`" + `{"moves":[]}` + "`" + `)) +} +` + if err := CheckSchema(code, "go"); err != nil { + t.Fatalf("expected complete bot to pass schema: %v", err) + } +} + +func TestCheckSchema_Go_MissingHealth(t *testing.T) { + code := `package main + +import "net/http" + +func main() { + http.HandleFunc("/turn", handleTurn) + http.ListenAndServe(":8080", nil) +} + +func handleTurn(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(` + "`" + `{"moves":[]}` + "`" + `)) +} +` + if err := CheckSchema(code, "go"); err == nil { + t.Fatal("expected missing /health to fail schema check") + } +} + +func TestCheckSchema_Go_MissingTurn(t *testing.T) { + code := `package main + +import "net/http" + +func main() { + http.HandleFunc("/health", handleHealth) + http.ListenAndServe(":8080", nil) +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) } +` + if err := CheckSchema(code, "go"); err == nil { + t.Fatal("expected missing /turn to fail schema check") + } +} + +func TestCheckSchema_Go_MissingMoves(t *testing.T) { + code := `package main + +import "net/http" + +func main() { + http.HandleFunc("/health", handleHealth) + http.HandleFunc("/turn", handleTurn) + http.ListenAndServe(":8080", nil) +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) } +func handleTurn(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(` + "`" + `{"result":"ok"}` + "`" + `)) +} +` + if err := CheckSchema(code, "go"); err == nil { + t.Fatal("expected missing moves field to fail schema check") + } +} + +func TestCheckSchema_UnsupportedLanguage(t *testing.T) { + if err := CheckSchema("code", "brainfuck"); err == nil { + t.Fatal("expected unsupported language to fail") + } +} + +// ── Pipeline tests ──────────────────────────────────────────────────────── + +func TestValidate_FailFastOnSyntax(t *testing.T) { + code := `package main +func main() { // missing closing brace` + cfg := DefaultConfig() + cfg.SandboxTimeout = 5 * time.Second + + report, err := Validate(context.Background(), code, "go", "raw llm output", cfg) + if err != nil { + t.Fatalf("Validate returned unexpected error: %v", err) + } + if report.Passed { + t.Fatal("expected pipeline to fail") + } + if report.LastStage() != StageSyntax { + t.Fatalf("expected to stop at syntax stage, got %q", report.LastStage()) + } + if len(report.Stages) != 1 { + t.Fatalf("expected 1 stage result (fail-fast), got %d", len(report.Stages)) + } + if report.LLMOutput != "raw llm output" { + t.Fatalf("LLMOutput not preserved: %q", report.LLMOutput) + } +} + +func TestValidate_FailFastOnSchema(t *testing.T) { + // Syntactically valid Go, but missing /turn endpoint. + code := `package main + +import "net/http" + +func main() { + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + http.ListenAndServe(":8080", nil) +} +` + cfg := DefaultConfig() + cfg.SandboxTimeout = 5 * time.Second + + report, err := Validate(context.Background(), code, "go", "", cfg) + if err != nil { + t.Fatalf("Validate returned unexpected error: %v", err) + } + if report.Passed { + t.Fatal("expected pipeline to fail") + } + if report.LastStage() != StageSchema { + t.Fatalf("expected to stop at schema stage, got %q", report.LastStage()) + } + if len(report.Stages) != 2 { + t.Fatalf("expected 2 stage results, got %d", len(report.Stages)) + } +} + +// TestValidate_FullPipeline_Go runs the complete pipeline including the +// sandbox smoke test using a minimal but complete Go bot. +// It is skipped when the `go` binary is not in PATH. +func TestValidate_FullPipeline_Go(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go not in PATH") + } + + code := minimalGoBot() + cfg := DefaultConfig() + cfg.UseNsjail = false // nsjail may not be available in CI + cfg.SmokeRequests = 5 + cfg.SandboxTimeout = 45 * time.Second + cfg.SyntaxTimeout = 10 * time.Second + + report, err := Validate(context.Background(), code, "go", "test llm output", cfg) + if err != nil { + t.Fatalf("Validate returned unexpected error: %v", err) + } + if !report.Passed { + for _, sr := range report.Stages { + if !sr.Passed { + t.Errorf("stage %s failed: %s", sr.Stage, sr.Error) + } + } + t.Fatalf("expected pipeline to pass") + } + if len(report.Stages) != 3 { + t.Fatalf("expected 3 stage results, got %d", len(report.Stages)) + } +} + +// minimalGoBot returns a minimal, complete Go bot that passes all three +// validation stages. It uses the reference signature format from bots/gatherer. +func minimalGoBot() string { + return `package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" +) + +type MoveResponse struct { + Moves []interface{} ` + "`json:\"moves\"`" + ` +} + +func verifySignature(secret, matchID, turnStr string, body []byte, signature string) error { + h := sha256.Sum256(body) + import_str := fmt.Sprintf("%s.%s.%s", matchID, turnStr, hex.EncodeToString(h[:])) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(import_str)) + expected := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(signature), []byte(expected)) { + return fmt.Errorf("invalid signature") + } + return nil +} + +func main() { + secret := os.Getenv("BOT_SECRET") + if secret == "" { + log.Fatal("BOT_SECRET required") + } + port := os.Getenv("BOT_PORT") + if port == "" { + port = "8080" + } + + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + http.HandleFunc("/turn", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + defer r.Body.Close() + + sig := r.Header.Get("X-ACB-Signature") + matchID := r.Header.Get("X-ACB-Match-Id") + turn := r.Header.Get("X-ACB-Turn") + if err := verifySignature(secret, matchID, turn, body, sig); err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + resp := MoveResponse{Moves: []interface{}{}} + out, _ := json.Marshal(resp) + w.Header().Set("Content-Type", "application/json") + w.Write(out) + }) + + log.Printf("bot starting on :%s", port) + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatal(err) + } +} +` +} diff --git a/cmd/acb-evolver/main.go b/cmd/acb-evolver/main.go new file mode 100644 index 0000000..393f567 --- /dev/null +++ b/cmd/acb-evolver/main.go @@ -0,0 +1,222 @@ +// acb-evolver manages the evolution pipeline programs database. +// +// Subcommands: +// +// init-schema Create or migrate the programs table in PostgreSQL +// seed Insert the 6 built-in strategy bots as generation-0 programs +// stats Print program counts per island +// validate Run the 3-stage validation pipeline on a bot source file +// validation-stats Show per-island validation pass-rate metrics +package main + +import ( + "context" + "database/sql" + "flag" + "fmt" + "log" + "os" + "strings" + + _ "github.com/lib/pq" + + evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db" + "github.com/aicodebattle/acb/cmd/acb-evolver/internal/validator" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: acb-evolver ") + os.Exit(1) + } + + dbURL := os.Getenv("ACB_DATABASE_URL") + if dbURL == "" { + dbURL = "postgres://localhost:5432/acb?sslmode=disable" + } + + ctx := context.Background() + + switch os.Args[1] { + case "init-schema": + db := mustOpenDB(dbURL) + defer db.Close() + if err := evolverdb.EnsureSchema(ctx, db); err != nil { + log.Fatalf("init-schema: %v", err) + } + log.Println("schema ready") + + case "seed": + db := mustOpenDB(dbURL) + defer db.Close() + store := evolverdb.NewStore(db) + if err := evolverdb.EnsureSchema(ctx, db); err != nil { + log.Fatalf("ensure schema: %v", err) + } + n, err := evolverdb.SeedPopulation(ctx, store) + if err != nil { + log.Fatalf("seed: %v", err) + } + if n == 0 { + log.Println("programs table already seeded; nothing to do") + } else { + log.Printf("seeded %d programs", n) + } + + case "stats": + db := mustOpenDB(dbURL) + defer db.Close() + store := evolverdb.NewStore(db) + counts, err := store.CountByIsland(ctx) + if err != nil { + log.Fatalf("stats: %v", err) + } + total := 0 + for _, island := range evolverdb.AllIslands { + n := counts[island] + total += n + fmt.Printf(" %-8s %d\n", island, n) + } + fmt.Printf(" %-8s %d\n", "total", total) + + case "validate": + runValidate(ctx, dbURL, os.Args[2:]) + + case "validation-stats": + db := mustOpenDB(dbURL) + defer db.Close() + store := evolverdb.NewStore(db) + runValidationStats(ctx, store) + + default: + fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1]) + fmt.Fprintln(os.Stderr, "usage: acb-evolver ") + os.Exit(1) + } +} + +// runValidate parses flags, runs the three-stage validation pipeline on a bot +// source file, and optionally logs the result to the database. +// +// validate -lang go [-island alpha] [-nsjail] +func runValidate(ctx context.Context, dbURL string, args []string) { + fs := flag.NewFlagSet("validate", flag.ExitOnError) + lang := fs.String("lang", "", "bot language (go|python|rust|typescript|java|php) [required]") + island := fs.String("island", "alpha", "island name for DB logging (alpha|beta|gamma|delta)") + useNsjail := fs.Bool("nsjail", false, "wrap sandbox in nsjail (requires nsjail in PATH)") + nolog := fs.Bool("nolog", false, "skip writing result to the database") + if err := fs.Parse(args); err != nil { + os.Exit(1) + } + + if *lang == "" { + fmt.Fprintln(os.Stderr, "validate: -lang is required") + fs.Usage() + os.Exit(1) + } + if fs.NArg() < 1 { + fmt.Fprintln(os.Stderr, "validate: file argument is required") + fs.Usage() + os.Exit(1) + } + + filePath := fs.Arg(0) + code, err := os.ReadFile(filePath) + if err != nil { + log.Fatalf("read file: %v", err) + } + + cfg := validator.DefaultConfig() + cfg.UseNsjail = *useNsjail + + report, err := validator.Validate(ctx, string(code), *lang, "", cfg) + if err != nil { + log.Fatalf("validate: %v", err) + } + + printReport(report, filePath) + + // Persist the result unless -nolog was set. + if !*nolog { + db, err := sql.Open("postgres", dbURL) + if err != nil { + log.Printf("warn: could not open DB for logging (%v) — skipping", err) + } else { + defer db.Close() + store := evolverdb.NewStore(db) + entry := &evolverdb.ValidationLog{ + Island: *island, + Language: *lang, + Stage: string(report.LastStage()), + Passed: report.Passed, + LLMOutput: report.LLMOutput, + } + if !report.Passed { + for _, sr := range report.Stages { + if !sr.Passed { + entry.ErrorText = sr.Error + break + } + } + } + if logErr := store.RecordValidation(ctx, entry); logErr != nil { + log.Printf("warn: DB log failed: %v", logErr) + } + } + } + + if !report.Passed { + os.Exit(1) + } +} + +// printReport prints a human-readable summary of the validation report. +func printReport(r *validator.Report, src string) { + fmt.Printf("Validation report for %s (%s)\n", src, r.Language) + fmt.Println(strings.Repeat("─", 50)) + for _, sr := range r.Stages { + status := "PASS" + if !sr.Passed { + status = "FAIL" + } + fmt.Printf(" %-8s %s (%s)\n", sr.Stage, status, sr.Duration.Round(1000000)) + if !sr.Passed && sr.Error != "" { + fmt.Printf(" %s\n", sr.Error) + } + } + fmt.Println(strings.Repeat("─", 50)) + if r.Passed { + fmt.Println(" Result: PASSED — all 3 stages OK") + } else { + fmt.Printf(" Result: FAILED at stage %q\n", r.LastStage()) + } +} + +// runValidationStats queries and prints per-island validation statistics. +func runValidationStats(ctx context.Context, store *evolverdb.Store) { + stats, err := store.IslandValidationStats(ctx) + if err != nil { + log.Fatalf("validation-stats: %v", err) + } + if len(stats) == 0 { + fmt.Println("no validation records found") + return + } + + fmt.Printf("%-8s %6s %6s %7s %7s %7s %7s\n", + "island", "total", "passed", "rate", "!syntax", "!schema", "!sandbox") + fmt.Println(strings.Repeat("─", 66)) + for _, v := range stats { + fmt.Printf("%-8s %6d %6d %6.1f%% %7d %7d %7d\n", + v.Island, v.Total, v.Passed, v.PassRate*100, + v.ByStage["syntax"], v.ByStage["schema"], v.ByStage["sandbox"]) + } +} + +func mustOpenDB(url string) *sql.DB { + db, err := sql.Open("postgres", url) + if err != nil { + log.Fatalf("open database: %v", err) + } + return db +}