Add validation pipeline, sandbox, and evolution DB layer (Phase 7)
Three-stage fail-fast validator for LLM-generated bot candidates: - syntax.go: language-aware parse (go/parser for Go; py_compile, rustfmt, tsc, javac, php -l for others; brace-balance fallback) - schema.go: regex detection of /health + /turn endpoints and "moves" field - sandbox.go: nsjail-isolated smoke test — builds bot, polls /health, sends 5 signed /turn requests, verifies JSON moves responses - validator.go: orchestrates stages with fail-fast short-circuit DB layer: - programs table + CRUD (create, get, list, updateFitness, setPromoted) - validation_log table with RecordValidation, IslandPassRates, IslandValidationStats for per-island pass-rate tracking - seed.go: 6 generation-0 bots across alpha/beta/gamma/delta islands MAP-Elites grid (mapelites/grid.go): 2-D behavior grid on aggression×economy axes; TryPlace keeps the fittest occupant per niche. acb-evolver CLI gains two new subcommands: validate <file> -lang <lang> [-island <island>] [-nsjail] [-nolog] validation-stats (tabular per-island pass-rate breakdown) cmd/acb-api/db.go: add programs table to API schema so the API can query promoted evolved bots. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bd4b0d3244
commit
5669688984
20 changed files with 4047 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
44
cmd/acb-evolver/internal/db/db.go
Normal file
44
cmd/acb-evolver/internal/db/db.go
Normal file
|
|
@ -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
|
||||
}
|
||||
179
cmd/acb-evolver/internal/db/programs.go
Normal file
179
cmd/acb-evolver/internal/db/programs.go
Normal file
|
|
@ -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
|
||||
}
|
||||
266
cmd/acb-evolver/internal/db/programs_test.go
Normal file
266
cmd/acb-evolver/internal/db/programs_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
126
cmd/acb-evolver/internal/db/seed.go
Normal file
126
cmd/acb-evolver/internal/db/seed.go
Normal file
|
|
@ -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
|
||||
}
|
||||
298
cmd/acb-evolver/internal/db/seeds/gatherer_strategy.go.txt
Normal file
298
cmd/acb-evolver/internal/db/seeds/gatherer_strategy.go.txt
Normal file
|
|
@ -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
|
||||
}
|
||||
364
cmd/acb-evolver/internal/db/seeds/guardian_strategy.php.txt
Normal file
364
cmd/acb-evolver/internal/db/seeds/guardian_strategy.php.txt
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
<?php
|
||||
/**
|
||||
* GuardianBot strategy: defensive core protection with cautious expansion.
|
||||
*
|
||||
* Strategy: Defend own core, gather nearby energy, cautious expansion.
|
||||
* - Maintain a perimeter of bots within 5 tiles of each owned core
|
||||
* - Assign excess bots to gather energy within 10 tiles of a core
|
||||
* - Consolidate defenders when enemies approach
|
||||
* - Only send scouts to explore beyond the safe zone
|
||||
* - Conservative spawning - maintains energy reserve of 6
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/game.php';
|
||||
|
||||
class GuardianStrategy {
|
||||
private const PERIMETER_RADIUS = 5;
|
||||
private const SAFE_ZONE_RADIUS = 10;
|
||||
private const ENERGY_RESERVE = 6;
|
||||
private const DIRECTIONS = ['N', 'E', 'S', 'W'];
|
||||
|
||||
/**
|
||||
* Compute moves for all owned bots
|
||||
*/
|
||||
public function computeMoves(GameState $state): array {
|
||||
$myId = $state->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}";
|
||||
}
|
||||
}
|
||||
394
cmd/acb-evolver/internal/db/seeds/hunter_strategy.java.txt
Normal file
394
cmd/acb-evolver/internal/db/seeds/hunter_strategy.java.txt
Normal file
|
|
@ -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<String, EnemyTracker> enemyTrackers = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Compute moves for all owned bots
|
||||
*/
|
||||
public List<Move> 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<VisibleBot> myBots = new ArrayList<>();
|
||||
List<VisibleBot> 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<String> walls = buildPositionSet(state.getWalls());
|
||||
Set<String> enemyPositions = buildPositionSet(
|
||||
enemyBots.stream().map(VisibleBot::getPosition).collect(Collectors.toList())
|
||||
);
|
||||
Set<String> myBotPositions = buildPositionSet(
|
||||
myBots.stream().map(VisibleBot::getPosition).collect(Collectors.toList())
|
||||
);
|
||||
|
||||
// Find isolated enemy targets
|
||||
List<VisibleBot> isolatedEnemies = findIsolatedEnemies(enemyBots, rows, cols);
|
||||
|
||||
// Find energy positions
|
||||
Set<String> energyPositions = buildPositionSet(state.getEnergy());
|
||||
|
||||
// Assign bots to targets
|
||||
List<Move> moves = new ArrayList<>();
|
||||
Set<String> usedEnergy = new HashSet<>();
|
||||
Set<Position> assignedTargets = new HashSet<>();
|
||||
|
||||
// First, assign hunters to isolated enemies
|
||||
Map<VisibleBot, VisibleBot> hunterAssignments = assignHunters(myBots, isolatedEnemies, rows, cols);
|
||||
|
||||
for (Map.Entry<VisibleBot, VisibleBot> 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<VisibleBot> 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<VisibleBot> findIsolatedEnemies(List<VisibleBot> enemyBots, int rows, int cols) {
|
||||
List<VisibleBot> 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<VisibleBot, VisibleBot> assignHunters(
|
||||
List<VisibleBot> myBots,
|
||||
List<VisibleBot> isolatedEnemies,
|
||||
int rows, int cols
|
||||
) {
|
||||
Map<VisibleBot, VisibleBot> assignments = new HashMap<>();
|
||||
|
||||
if (isolatedEnemies.isEmpty()) {
|
||||
return assignments;
|
||||
}
|
||||
|
||||
// Sort my bots by distance to nearest isolated enemy
|
||||
List<VisibleBot> 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<VisibleBot> 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<String> enemyPositions,
|
||||
Set<String> walls,
|
||||
Set<String> 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<String> energyPositions,
|
||||
Set<String> usedEnergy,
|
||||
Set<String> enemyPositions,
|
||||
Set<String> 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<String> enemyPositions,
|
||||
Set<String> 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<String> 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<String> buildPositionSet(List<Position> 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);
|
||||
}
|
||||
}
|
||||
163
cmd/acb-evolver/internal/db/seeds/random_main.py.txt
Normal file
163
cmd/acb-evolver/internal/db/seeds/random_main.py.txt
Normal file
|
|
@ -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()
|
||||
192
cmd/acb-evolver/internal/db/seeds/rusher_strategy.rs.txt
Normal file
192
cmd/acb-evolver/internal/db/seeds/rusher_strategy.rs.txt
Normal file
|
|
@ -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<Position>,
|
||||
}
|
||||
|
||||
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<Move> {
|
||||
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<Position> =
|
||||
enemy_bots.iter().map(|b| b.position).collect();
|
||||
|
||||
// Build wall lookup
|
||||
let walls: HashSet<Position> = 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<Position> = 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<Position> {
|
||||
let mut targets: Vec<Position> = 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<Position>,
|
||||
walls: &HashSet<Position>,
|
||||
_assigned_targets: &HashSet<Position>,
|
||||
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<Position> = HashSet::new();
|
||||
let mut queue: VecDeque<(Position, Option<Direction>)> = 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<Position> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
228
cmd/acb-evolver/internal/db/seeds/swarm_strategy.ts.txt
Normal file
228
cmd/acb-evolver/internal/db/seeds/swarm_strategy.ts.txt
Normal file
|
|
@ -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<string, VisibleBot>();
|
||||
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<string>,
|
||||
enemyPositions: Map<string, VisibleBot>,
|
||||
swarmCenter: Position,
|
||||
enemyCenter: Position | null,
|
||||
walls: Set<string>,
|
||||
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<string>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
112
cmd/acb-evolver/internal/db/validation_log.go
Normal file
112
cmd/acb-evolver/internal/db/validation_log.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
115
cmd/acb-evolver/internal/mapelites/grid.go
Normal file
115
cmd/acb-evolver/internal/mapelites/grid.go
Normal file
|
|
@ -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
|
||||
}
|
||||
170
cmd/acb-evolver/internal/mapelites/grid_test.go
Normal file
170
cmd/acb-evolver/internal/mapelites/grid_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
420
cmd/acb-evolver/internal/validator/sandbox.go
Normal file
420
cmd/acb-evolver/internal/validator/sandbox.go
Normal file
|
|
@ -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
|
||||
}
|
||||
78
cmd/acb-evolver/internal/validator/schema.go
Normal file
78
cmd/acb-evolver/internal/validator/schema.go
Normal file
|
|
@ -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
|
||||
}
|
||||
175
cmd/acb-evolver/internal/validator/syntax.go
Normal file
175
cmd/acb-evolver/internal/validator/syntax.go
Normal file
|
|
@ -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]) + "…"
|
||||
}
|
||||
139
cmd/acb-evolver/internal/validator/validator.go
Normal file
139
cmd/acb-evolver/internal/validator/validator.go
Normal file
|
|
@ -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
|
||||
}
|
||||
347
cmd/acb-evolver/internal/validator/validator_test.go
Normal file
347
cmd/acb-evolver/internal/validator/validator_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
222
cmd/acb-evolver/main.go
Normal file
222
cmd/acb-evolver/main.go
Normal file
|
|
@ -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 <init-schema|seed|stats|validate|validation-stats>")
|
||||
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 <init-schema|seed|stats|validate|validation-stats>")
|
||||
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] <file>
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue