ai-code-battle/cmd/acb-evolver/internal/db/programs.go
jedarden 76e8791e4d Add evaluation arena, promotion gate, and retirement policy (Phase 7)
- arena/arena.go: 10-match mini-tournament running candidate as a local
  subprocess against diverse live opponents sampled across the rating
  distribution; AES-GCM secret decryption for opponent auth
- arena/psro.go: Nash equilibrium computation for the 1×K meta-game;
  FictitiousPlayNash included for future K×K support
- arena/winrate.go: Wilson-score 95% CI for win-rate calculation; draws
  counted as 0.5 wins
- arena/gate.go: two-part promotion gate — Nash value ≥ threshold AND
  MAP-Elites niche fill or improvement; detailed reason strings
- promoter/promoter.go: full promotion pipeline — bot source + Dockerfile
  + K8s Secret/Deployment/Service manifests, docker build, git commit/push
  (ArgoCD sync), kubectl readiness poll, bots-table INSERT, programs-table
  update; RetireBot and EnforcePolicy (rating threshold + population cap 50)
- db/db.go: add bot_name / bot_secret migration columns
- db/programs.go: ListPromoted, SetBotNameAndSecret, UnsetPromoted,
  GetByBotID, PromotedCount helpers for promotion/retirement lifecycle
- main.go: evaluate and retire subcommands wiring arena + gate + promoter;
  remove unused island flag from evaluate
- arena/arena_test.go: 21 unit tests covering Nash, Wilson CI, Gate logic,
  and selectDiverse opponent sampling
- promoter/promoter_test.go: tests for Dockerfiles, bot-ID/secret generation,
  AES-GCM helpers, and K8s manifest templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 23:32:37 -04:00

282 lines
8.7 KiB
Go

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
}
// SetBotID records the deployed bot_id for a promoted program.
func (s *Store) SetBotID(ctx context.Context, id int64, botID string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE programs SET bot_id = $1 WHERE id = $2`, botID, id)
if err != nil {
return fmt.Errorf("set bot_id for program %d: %w", id, err)
}
return nil
}
// PromotedProgram holds a promoted program linked to its live bot.
type PromotedProgram struct {
ProgramID int64
BotID string
BotName string // K8s/API name, e.g. "acb-evo-42"
BotSecret string // plaintext secret stored for retirement operations
Island string
BehaviorVector []float64
Fitness float64
}
// ListPromoted returns all programs that have been promoted (bot_id is set).
func (s *Store) ListPromoted(ctx context.Context) ([]*PromotedProgram, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, bot_id, COALESCE(bot_name, ''), COALESCE(bot_secret, ''),
island, behavior_vector, fitness
FROM programs
WHERE promoted = TRUE AND bot_id IS NOT NULL
ORDER BY fitness DESC`)
if err != nil {
return nil, fmt.Errorf("list promoted programs: %w", err)
}
defer rows.Close()
var out []*PromotedProgram
for rows.Next() {
p := &PromotedProgram{}
if err := rows.Scan(&p.ProgramID, &p.BotID, &p.BotName, &p.BotSecret,
&p.Island, pq.Array(&p.BehaviorVector), &p.Fitness); err != nil {
return nil, fmt.Errorf("scan promoted program: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
// SetBotNameAndSecret records the K8s bot name and plaintext shared secret for
// a promoted program. These are stored so the retirement path can locate and
// clean up the bot without requiring an extra API call.
func (s *Store) SetBotNameAndSecret(ctx context.Context, id int64, botName, botSecret string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE programs SET bot_name = $1, bot_secret = $2 WHERE id = $3`,
botName, botSecret, id)
if err != nil {
return fmt.Errorf("set bot name/secret for program %d: %w", id, err)
}
return nil
}
// PromotedCount returns the number of currently promoted (deployed) programs.
func (s *Store) PromotedCount(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM programs WHERE promoted = TRUE AND bot_id IS NOT NULL`).Scan(&n)
if err != nil {
return 0, fmt.Errorf("promoted count: %w", err)
}
return n, nil
}
// UnsetPromoted clears the promoted flag and bot_id for a retired program.
func (s *Store) UnsetPromoted(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx,
`UPDATE programs SET promoted = FALSE, bot_id = NULL WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("unset promoted for program %d: %w", id, err)
}
return nil
}
// GetByBotID returns the program associated with a deployed bot ID, or nil.
func (s *Store) GetByBotID(ctx context.Context, botID string) (*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 bot_id = $1`, botID).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 by bot_id %s: %w", botID, err)
}
if err := json.Unmarshal([]byte(parentJSON), &p.ParentIDs); err != nil {
return nil, fmt.Errorf("unmarshal parent_ids: %w", err)
}
return p, nil
}