diff --git a/cmd/acb-api/server.go b/cmd/acb-api/server.go
index 85cd9aa..7c60bae 100644
--- a/cmd/acb-api/server.go
+++ b/cmd/acb-api/server.go
@@ -45,6 +45,10 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
// UI feedback (Agentation overlay)
mux.HandleFunc("POST /api/ui-feedback", s.handleUIFeedback)
+
+ // Predictions
+ mux.HandleFunc("POST /api/predict", s.handlePredict)
+ mux.HandleFunc("GET /api/predictions/open", s.handleOpenPredictions)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -736,6 +740,162 @@ func (s *Server) handleListBots(w http.ResponseWriter, r *http.Request) {
})
}
+// handlePredict handles POST /api/predict
+// Accepts {match_id, bot_id, confidence} and writes to predictions table.
+// Rejects if the match has already started (status != 'pending').
+func (s *Server) handlePredict(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ writeError(w, http.StatusMethodNotAllowed, "method not allowed")
+ return
+ }
+
+ var req struct {
+ MatchID string `json:"match_id"`
+ BotID string `json:"bot_id"`
+ Predictor string `json:"predictor_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ if req.MatchID == "" || req.BotID == "" || req.Predictor == "" {
+ writeError(w, http.StatusBadRequest, "match_id, bot_id, and predictor_id are required")
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
+ defer cancel()
+
+ // Verify match exists and hasn't started
+ var matchStatus string
+ err := s.db.QueryRowContext(ctx, `
+ SELECT status FROM matches WHERE match_id = $1
+ `, req.MatchID).Scan(&matchStatus)
+ if err == sql.ErrNoRows {
+ writeError(w, http.StatusNotFound, "match not found")
+ return
+ } else if err != nil {
+ log.Printf("database error checking match: %v", err)
+ writeError(w, http.StatusInternalServerError, "database error")
+ return
+ }
+
+ if matchStatus != "pending" {
+ writeError(w, http.StatusConflict, "match has already started")
+ return
+ }
+
+ // Verify bot is a participant in this match
+ var participantExists bool
+ err = s.db.QueryRowContext(ctx, `
+ SELECT EXISTS(
+ SELECT 1 FROM match_participants
+ WHERE match_id = $1 AND bot_id = $2
+ )
+ `, req.MatchID, req.BotID).Scan(&participantExists)
+ if err != nil {
+ log.Printf("database error checking participant: %v", err)
+ writeError(w, http.StatusInternalServerError, "database error")
+ return
+ }
+ if !participantExists {
+ writeError(w, http.StatusBadRequest, "bot is not a participant in this match")
+ return
+ }
+
+ // Insert prediction (UNIQUE constraint handles duplicates)
+ var predictionID int64
+ err = s.db.QueryRowContext(ctx, `
+ INSERT INTO predictions (match_id, predictor_id, predicted_bot)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (match_id, predictor_id) DO UPDATE SET predicted_bot = $3
+ RETURNING id
+ `, req.MatchID, req.Predictor, req.BotID).Scan(&predictionID)
+ if err != nil {
+ log.Printf("failed to insert prediction: %v", err)
+ writeError(w, http.StatusInternalServerError, "failed to submit prediction")
+ return
+ }
+
+ writeJSON(w, http.StatusCreated, map[string]interface{}{
+ "id": predictionID,
+ "match_id": req.MatchID,
+ "predicted": req.BotID,
+ "predictor": req.Predictor,
+ })
+}
+
+// handleOpenPredictions handles GET /api/predictions/open
+// Returns pending matches that are open for predictions, along with
+// any predictions the requesting predictor has made.
+func (s *Server) handleOpenPredictions(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ writeError(w, http.StatusMethodNotAllowed, "method not allowed")
+ return
+ }
+
+ predictorID := r.URL.Query().Get("predictor_id")
+
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
+ defer cancel()
+
+ // Get pending matches with their participants
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT m.match_id, m.created_at,
+ COALESCE(json_agg(json_build_object('bot_id', mp.bot_id, 'name', b.name, 'rating', b.rating_mu - 2*b.rating_phi)) FILTER (WHERE mp.bot_id IS NOT NULL), '[]')
+ FROM matches m
+ JOIN match_participants mp ON m.match_id = mp.match_id
+ JOIN bots b ON mp.bot_id = b.bot_id
+ WHERE m.status = 'pending'
+ GROUP BY m.match_id, m.created_at
+ ORDER BY m.created_at ASC
+ LIMIT 20
+ `)
+ if err != nil {
+ log.Printf("database error fetching open matches: %v", err)
+ writeError(w, http.StatusInternalServerError, "database error")
+ return
+ }
+ defer rows.Close()
+
+ type MatchPrediction struct {
+ MatchID string `json:"match_id"`
+ CreatedAt string `json:"created_at"`
+ Participants []map[string]interface{} `json:"participants"`
+ YourPick *string `json:"your_pick,omitempty"`
+ }
+
+ var matches []MatchPrediction
+ for rows.Next() {
+ var m MatchPrediction
+ var participantsJSON string
+ if err := rows.Scan(&m.MatchID, &m.CreatedAt, &participantsJSON); err != nil {
+ log.Printf("error scanning match: %v", err)
+ continue
+ }
+ json.Unmarshal([]byte(participantsJSON), &m.Participants)
+
+ // If predictor_id given, look up their existing prediction
+ if predictorID != "" {
+ var predictedBot string
+ err := s.db.QueryRowContext(ctx, `
+ SELECT predicted_bot FROM predictions
+ WHERE match_id = $1 AND predictor_id = $2
+ `, m.MatchID, predictorID).Scan(&predictedBot)
+ if err == nil {
+ m.YourPick = &predictedBot
+ }
+ }
+
+ matches = append(matches, m)
+ }
+
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "matches": matches,
+ })
+}
+
// handleUIFeedback handles POST /api/ui-feedback
// Accepts Agentation UI feedback (annotations, issues, etc.).
// Stores in database or logs to disk.
diff --git a/cmd/acb-evolver/internal/live/exporter.go b/cmd/acb-evolver/internal/live/exporter.go
index b8f1469..0c71c22 100644
--- a/cmd/acb-evolver/internal/live/exporter.go
+++ b/cmd/acb-evolver/internal/live/exporter.go
@@ -59,15 +59,94 @@ type MetaSnapshot struct {
IslandBestFitness map[string]float64 `json:"island_best_fitness"`
}
-// LiveData is the full evolution dashboard payload written to live.json.
+// CycleInfo represents the current evolution cycle status.
+type CycleInfo struct {
+ Generation int `json:"generation"`
+ StartedAt string `json:"started_at"`
+ Phase string `json:"phase"` // generating, validating, evaluating, promoting, idle
+ Candidate *Candidate `json:"candidate,omitempty"`
+}
+
+// Candidate represents the current candidate being evaluated.
+type Candidate struct {
+ ID string `json:"id"` // e.g., "go-847-3"
+ Island string `json:"island"`
+ Language string `json:"language"`
+ Parents []ParentInfo `json:"parents"`
+ Validation *ValidationStatus `json:"validation,omitempty"`
+ Evaluation *EvaluationStatus `json:"evaluation,omitempty"`
+}
+
+// ParentInfo holds parent bot information.
+type ParentInfo struct {
+ ID string `json:"id"` // e.g., "go-831-1"
+ Rating int `json:"rating"`
+}
+
+// ValidationStatus holds validation stage results.
+type ValidationStatus struct {
+ Syntax *StageResult `json:"syntax,omitempty"`
+ Schema *StageResult `json:"schema,omitempty"`
+ Smoke *StageResult `json:"smoke,omitempty"`
+}
+
+// StageResult holds result for a single validation stage.
+type StageResult struct {
+ Passed bool `json:"passed"`
+ TimeMs int `json:"time_ms"`
+ Error string `json:"error,omitempty"`
+}
+
+// EvaluationStatus holds arena evaluation results.
+type EvaluationStatus struct {
+ MatchesTotal int `json:"matches_total"`
+ MatchesPlayed int `json:"matches_played"`
+ Results []MatchResult `json:"results"`
+}
+
+// MatchResult is a single evaluation match result.
+type MatchResult struct {
+ Opponent string `json:"opponent"` // opponent bot name
+ Won bool `json:"won"`
+ Score string `json:"score"` // e.g., "5-1"
+}
+
+// ActivityEntry is a single event in the recent activity feed.
+type ActivityEntry struct {
+ Time string `json:"time"`
+ Generation int `json:"generation"`
+ Candidate string `json:"candidate"`
+ Island string `json:"island"`
+ Result string `json:"result"` // promoted, rejected
+ Reason string `json:"reason"`
+ Stage string `json:"stage"` // validation, promotion, deployment
+ BotID string `json:"bot_id,omitempty"`
+ InitialRating int `json:"initial_rating,omitempty"`
+}
+
+// Totals holds overall evolution statistics.
+type Totals struct {
+ GenerationsTotal int `json:"generations_total"`
+ CandidatesToday int `json:"candidates_today"`
+ PromotedToday int `json:"promoted_today"`
+ PromotionRate7d float64 `json:"promotion_rate_7d"`
+ HighestEvolvedRating int `json:"highest_evolved_rating"`
+ EvolvedInTop10 int `json:"evolved_in_top_10"`
+}
+
+// LiveData is the full evolution dashboard payload written to live.json (plan §14 format).
type LiveData struct {
- UpdatedAt string `json:"updated_at"`
- TotalPrograms int `json:"total_programs"`
- PromotedCount int `json:"promoted_count"`
- Islands map[string]IslandStat `json:"islands"`
- GenerationLog []GenerationEntry `json:"generation_log"`
- Lineage []LineageNode `json:"lineage"`
- MetaSnapshots []MetaSnapshot `json:"meta_snapshots"`
+ UpdatedAt string `json:"updated_at"`
+ Cycle *CycleInfo `json:"cycle,omitempty"`
+ RecentActivity []ActivityEntry `json:"recent_activity,omitempty"`
+ Islands map[string]IslandStat `json:"islands"`
+ Totals Totals `json:"totals"`
+ // Legacy fields for backward compatibility
+ TotalPrograms int `json:"total_programs,omitempty"`
+ PromotedCount int `json:"promoted_count,omitempty"`
+ GenerationLog []GenerationEntry `json:"generation_log,omitempty"`
+ Lineage []LineageNode `json:"lineage,omitempty"`
+ MetaSnapshots []MetaSnapshot `json:"meta_snapshots,omitempty"`
}
// Export queries the programs database and builds the current evolution state.
@@ -75,11 +154,19 @@ func Export(ctx context.Context, db *sql.DB) (*LiveData, error) {
data := &LiveData{
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
Islands: make(map[string]IslandStat),
+ Totals: Totals{},
}
if err := fillIslandStats(ctx, db, data); err != nil {
return nil, err
}
+ if err := fillTotals(ctx, db, data); err != nil {
+ return nil, err
+ }
+ if err := fillRecentActivity(ctx, db, data); err != nil {
+ return nil, err
+ }
+ // Legacy fields for backward compatibility
if err := fillGenerationLog(ctx, db, data); err != nil {
return nil, err
}
@@ -94,50 +181,168 @@ func Export(ctx context.Context, db *sql.DB) (*LiveData, error) {
}
func fillIslandStats(ctx context.Context, db *sql.DB, data *LiveData) error {
+ // Query island stats with bot ratings
rows, err := db.QueryContext(ctx, `
- SELECT island,
- COUNT(*) AS cnt,
- COALESCE(AVG(fitness), 0) AS avg_fit,
- COALESCE(MAX(fitness), 0) AS max_fit,
- COUNT(DISTINCT language) AS lang_diversity,
- SUM(CASE WHEN promoted AND bot_id IS NOT NULL THEN 1 ELSE 0 END) AS promoted_cnt
- FROM programs
- GROUP BY island`)
+ SELECT p.island,
+ COUNT(*) AS population,
+ COALESCE(MAX(b.rating_mu - 2*b.rating_phi), 0) AS best_rating,
+ COALESCE(b.bot_id, '') AS best_bot_id
+ FROM programs p
+ LEFT JOIN bots b ON p.bot_id = b.bot_id
+ GROUP BY p.island`)
if err != nil {
return fmt.Errorf("island stats: %w", err)
}
defer rows.Close()
total := 0
- promoted := 0
for rows.Next() {
var island string
- var cnt, langDiv, promotedCnt int
- var avgFit, maxFit float64
- if err := rows.Scan(&island, &cnt, &avgFit, &maxFit, &langDiv, &promotedCnt); err != nil {
+ var population, bestRating int
+ var bestBotID string
+ if err := rows.Scan(&island, &population, &bestRating, &bestBotID); err != nil {
return fmt.Errorf("scan island stats: %w", err)
}
- // Diversity: language diversity normalized to [0,1], up to 6 languages
- const maxLangs = 6.0
- diversity := float64(langDiv) / maxLangs
- if diversity > 1.0 {
- diversity = 1.0
- }
+
data.Islands[island] = IslandStat{
- Count: cnt,
- BestFitness: round3(maxFit),
- AvgFitness: round3(avgFit),
- Diversity: round3(diversity),
- PromotedCount: promotedCnt,
+ Population: population,
+ BestRating: bestRating,
+ BestBot: bestBotID,
}
- total += cnt
- promoted += promotedCnt
+ total += population
}
if err := rows.Err(); err != nil {
return err
}
data.TotalPrograms = total
- data.PromotedCount = promoted
+ return nil
+}
+
+func fillTotals(ctx context.Context, db *sql.DB, data *LiveData) error {
+ // Get max generation
+ var maxGen int
+ err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(generation), 0) FROM programs`).Scan(&maxGen)
+ if err != nil {
+ return fmt.Errorf("max generation: %w", err)
+ }
+
+ // Count candidates created today
+ var candidatesToday int
+ today := time.Now().UTC().Format("2006-01-02")
+ err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM programs WHERE created_at >= $1::date`, today).Scan(&candidatesToday)
+ if err != nil {
+ return fmt.Errorf("candidates today: %w", err)
+ }
+
+ // Count promoted today
+ var promotedToday int
+ err = db.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM programs p
+ JOIN bots b ON p.bot_id = b.bot_id
+ WHERE p.promoted = TRUE AND b.created_at >= $1::date`, today).Scan(&promotedToday)
+ if err != nil {
+ return fmt.Errorf("promoted today: %w", err)
+ }
+
+ // Calculate 7-day promotion rate
+ var promoted7d, total7d int
+ err = db.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM programs p
+ JOIN bots b ON p.bot_id = b.bot_id
+ WHERE b.created_at >= NOW() - INTERVAL '7 days'`).Scan(&promoted7d)
+ if err != nil {
+ promoted7d = 0
+ }
+ err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM programs WHERE created_at >= NOW() - INTERVAL '7 days'`).Scan(&total7d)
+ if err != nil {
+ total7d = 0
+ }
+ var rate7d float64
+ if total7d > 0 {
+ rate7d = round3(float64(promoted7d) / float64(total7d))
+ }
+
+ // Highest evolved rating
+ var highestRating int
+ err = db.QueryRowContext(ctx, `
+ SELECT COALESCE(MAX(b.rating_mu - 2*b.rating_phi), 0)
+ FROM bots b
+ WHERE b.owner = 'acb-evolver'`).Scan(&highestRating)
+ if err != nil {
+ highestRating = 0
+ }
+
+ // Count evolved in top 10
+ var top10Count int
+ err = db.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM (
+ SELECT b.bot_id, b.rating_mu - 2*b.rating_phi AS display_rating
+ FROM bots b
+ WHERE b.status = 'active'
+ ORDER BY display_rating DESC
+ LIMIT 10
+ ) top10
+ JOIN bots b ON top10.bot_id = b.bot_id
+ WHERE b.owner = 'acb-evolver'`).Scan(&top10Count)
+ if err != nil {
+ top10Count = 0
+ }
+
+ data.Totals = Totals{
+ GenerationsTotal: maxGen,
+ CandidatesToday: candidatesToday,
+ PromotedToday: promotedToday,
+ PromotionRate7d: rate7d,
+ HighestEvolvedRating: highestRating,
+ EvolvedInTop10: top10Count,
+ }
+
+ return nil
+}
+
+func fillRecentActivity(ctx context.Context, db *sql.DB, data *LiveData) error {
+ // Get recent promoted bots from bots table (with timestamps)
+ rows, err := db.QueryContext(ctx, `
+ SELECT
+ p.bot_id,
+ p.bot_name,
+ p.island,
+ p.generation,
+ p.language,
+ b.created_at
+ FROM programs p
+ JOIN bots b ON p.bot_id = b.bot_id
+ WHERE p.promoted = TRUE AND b.owner = 'acb-evolver'
+ ORDER BY b.created_at DESC
+ LIMIT 10`)
+ if err != nil && err != sql.ErrNoRows {
+ return fmt.Errorf("recent activity: %w", err)
+ }
+ defer rows.Close()
+
+ activities := []ActivityEntry{}
+ for rows.Next() {
+ var botID, botName, island, language string
+ var generation int
+ var createdAt time.Time
+ if err := rows.Scan(&botID, &botName, &island, &generation, &language, &createdAt); err != nil {
+ continue
+ }
+ activities = append(activities, ActivityEntry{
+ Time: createdAt.UTC().Format(time.RFC3339),
+ Generation: generation,
+ Candidate: botName,
+ Island: island,
+ Result: "promoted",
+ Reason: "Passed promotion gate",
+ Stage: "deployment",
+ BotID: botID,
+ })
+ }
+ data.RecentActivity = activities
+ data.PromotedCount = len(activities)
+
return nil
}
diff --git a/cmd/acb-matchmaker/config.go b/cmd/acb-matchmaker/config.go
index 32c3e4f..b35deca 100644
--- a/cmd/acb-matchmaker/config.go
+++ b/cmd/acb-matchmaker/config.go
@@ -23,3 +23,15 @@ func envInt(key string, fallback int) int {
}
return n
}
+
+func envFloat(key string, fallback float64) float64 {
+ v := os.Getenv(key)
+ if v == "" {
+ return fallback
+ }
+ n, err := strconv.ParseFloat(v, 64)
+ if err != nil {
+ return fallback
+ }
+ return n
+}
diff --git a/cmd/acb-matchmaker/main.go b/cmd/acb-matchmaker/main.go
index 79f2571..2833c06 100644
--- a/cmd/acb-matchmaker/main.go
+++ b/cmd/acb-matchmaker/main.go
@@ -24,12 +24,15 @@ type Config struct {
EncryptionKey string // AES-256-GCM key for shared secret decryption
DiscordWebhook string
SlackWebhook string
- MatchmakerSecs int
- HealthCheckSecs int
- ReaperSecs int
- BotTimeoutSecs int
- StaleJobMinutes int
- MaxConsecFails int
+ MatchmakerSecs int
+ HealthCheckSecs int
+ ReaperSecs int
+ SeriesSchedSecs int
+ SeasonResetSecs int
+ BotTimeoutSecs int
+ StaleJobMinutes int
+ MaxConsecFails int
+ SeasonDecayFactor float64
}
type Matchmaker struct {
@@ -47,12 +50,15 @@ func loadConfig() Config {
EncryptionKey: os.Getenv("ACB_ENCRYPTION_KEY"),
DiscordWebhook: os.Getenv("ACB_DISCORD_WEBHOOK"),
SlackWebhook: os.Getenv("ACB_SLACK_WEBHOOK"),
- MatchmakerSecs: envInt("ACB_MATCHMAKER_INTERVAL", 60),
- HealthCheckSecs: envInt("ACB_HEALTHCHECK_INTERVAL", 900),
- ReaperSecs: envInt("ACB_REAPER_INTERVAL", 300),
- BotTimeoutSecs: envInt("ACB_BOT_TIMEOUT", 5),
- StaleJobMinutes: envInt("ACB_STALE_JOB_MINUTES", 15),
- MaxConsecFails: envInt("ACB_MAX_CONSEC_FAILS", 3),
+ MatchmakerSecs: envInt("ACB_MATCHMAKER_INTERVAL", 60),
+ HealthCheckSecs: envInt("ACB_HEALTHCHECK_INTERVAL", 900),
+ ReaperSecs: envInt("ACB_REAPER_INTERVAL", 300),
+ SeriesSchedSecs: envInt("ACB_SERIES_SCHED_INTERVAL", 120),
+ SeasonResetSecs: envInt("ACB_SEASON_RESET_INTERVAL", 300),
+ BotTimeoutSecs: envInt("ACB_BOT_TIMEOUT", 5),
+ StaleJobMinutes: envInt("ACB_STALE_JOB_MINUTES", 15),
+ MaxConsecFails: envInt("ACB_MAX_CONSEC_FAILS", 3),
+ SeasonDecayFactor: envFloat("ACB_SEASON_DECAY_FACTOR", 0.7),
}
}
diff --git a/cmd/acb-matchmaker/series_season.go b/cmd/acb-matchmaker/series_season.go
new file mode 100644
index 0000000..6e459b0
--- /dev/null
+++ b/cmd/acb-matchmaker/series_season.go
@@ -0,0 +1,580 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log"
+ "math/rand"
+ "time"
+)
+
+// tickSeriesScheduler schedules remaining games for active series.
+// For each active series with unplayed games, it schedules the next game
+// in round-robin order, feeding the match into the job queue.
+// It also marks series as completed when a bot reaches the winning threshold.
+func (m *Matchmaker) tickSeriesScheduler(ctx context.Context) {
+ // 1. Finalize any completed series (check if winner reached threshold)
+ if err := m.finalizeCompletedSeries(ctx); err != nil {
+ log.Printf("series-scheduler: finalize error: %v", err)
+ }
+
+ // 2. Schedule next game for active series that need one
+ if err := m.scheduleNextSeriesGames(ctx); err != nil {
+ log.Printf("series-scheduler: schedule error: %v", err)
+ }
+
+ // 3. Auto-create series for top bots (one per bot per day, best-of-5)
+ if err := m.autoCreateSeries(ctx); err != nil {
+ log.Printf("series-scheduler: auto-create error: %v", err)
+ }
+}
+
+// finalizeCompletedSeries checks active series where one bot has already won enough games.
+func (m *Matchmaker) finalizeCompletedSeries(ctx context.Context) error {
+ // Find active series where a_wins or b_wins >= ceil(format/2)
+ rows, err := m.db.QueryContext(ctx, `
+ SELECT id, bot_a_id, bot_b_id, format, a_wins, b_wins
+ FROM series
+ WHERE status = 'active'
+ AND (a_wins >= ((format + 1) / 2) OR b_wins >= ((format + 1) / 2))
+ `)
+ if err != nil {
+ return fmt.Errorf("query completed series: %w", err)
+ }
+ defer rows.Close()
+
+ type completedSeries struct {
+ ID int64
+ BotAID string
+ BotBID string
+ Format int
+ AWins int
+ BWins int
+ }
+ var completed []completedSeries
+
+ for rows.Next() {
+ var s completedSeries
+ if err := rows.Scan(&s.ID, &s.BotAID, &s.BotBID, &s.Format, &s.AWins, &s.BWins); err != nil {
+ return fmt.Errorf("scan series: %w", err)
+ }
+ completed = append(completed, s)
+ }
+
+ for _, s := range completed {
+ winsNeeded := (s.Format + 1) / 2
+ var winnerID string
+ if s.AWins >= winsNeeded {
+ winnerID = s.BotAID
+ } else {
+ winnerID = s.BotBID
+ }
+
+ _, err := m.db.ExecContext(ctx, `
+ UPDATE series
+ SET status = 'completed', winner_id = $1, updated_at = NOW()
+ WHERE id = $2 AND status = 'active'
+ `, winnerID, s.ID)
+ if err != nil {
+ log.Printf("series-scheduler: failed to finalize series %d: %v", s.ID, err)
+ continue
+ }
+ log.Printf("series-scheduler: finalized series %d, winner=%s (%d-%d)", s.ID, winnerID, s.AWins, s.BWins)
+ }
+
+ return nil
+}
+
+// scheduleNextSeriesGames finds active series with unplayed games and schedules the next one.
+func (m *Matchmaker) scheduleNextSeriesGames(ctx context.Context) error {
+ // Find active series where the next sequential game has no match_id yet
+ rows, err := m.db.QueryContext(ctx, `
+ SELECT s.id, s.bot_a_id, s.bot_b_id, s.format, s.a_wins, s.b_wins,
+ COALESCE(MAX(sg.game_num), 0) AS last_game_num
+ FROM series s
+ LEFT JOIN series_games sg ON s.id = sg.series_id AND sg.match_id IS NOT NULL
+ WHERE s.status = 'active'
+ GROUP BY s.id, s.bot_a_id, s.bot_b_id, s.format, s.a_wins, s.b_wins
+ HAVING COUNT(sg.id) < s.format
+ `)
+ if err != nil {
+ return fmt.Errorf("query pending series: %w", err)
+ }
+ defer rows.Close()
+
+ type pendingSeries struct {
+ ID int64
+ BotAID string
+ BotBID string
+ Format int
+ AWins int
+ BWins int
+ LastGameNum int
+ }
+ var pending []pendingSeries
+
+ for rows.Next() {
+ var s pendingSeries
+ if err := rows.Scan(&s.ID, &s.BotAID, &s.BotBID, &s.Format, &s.AWins, &s.BWins, &s.LastGameNum); err != nil {
+ return fmt.Errorf("scan pending series: %w", err)
+ }
+
+ // Skip if series is already decided
+ winsNeeded := (s.Format + 1) / 2
+ if s.AWins >= winsNeeded || s.BWins >= winsNeeded {
+ continue
+ }
+
+ // Check that both bots are active
+ var aActive, bActive bool
+ err := m.db.QueryRowContext(ctx,
+ `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active')`, s.BotAID).Scan(&aActive)
+ if err != nil {
+ continue
+ }
+ err = m.db.QueryRowContext(ctx,
+ `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active')`, s.BotBID).Scan(&bActive)
+ if err != nil {
+ continue
+ }
+ if !aActive || !bActive {
+ continue
+ }
+
+ pending = append(pending, s)
+ }
+
+ rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+
+ for _, s := range pending {
+ nextGameNum := s.LastGameNum + 1
+
+ // Check if this game already has a pending match (not yet completed)
+ var existingMatch int
+ err := m.db.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM series_games
+ WHERE series_id = $1 AND game_num = $2 AND match_id IS NOT NULL
+ `, s.ID, nextGameNum).Scan(&existingMatch)
+ if err != nil || existingMatch > 0 {
+ continue // already scheduled or played
+ }
+
+ // Check if there's already a pending/running job for this series
+ var pendingJobs int
+ err = m.db.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM series_games sg
+ JOIN matches m ON sg.match_id = m.match_id
+ JOIN jobs j ON j.match_id = m.match_id
+ WHERE sg.series_id = $1 AND j.status IN ('pending', 'running')
+ `, s.ID).Scan(&pendingJobs)
+ if err != nil || pendingJobs > 0 {
+ continue // a game is already in progress for this series
+ }
+
+ if err := m.scheduleSeriesGame(ctx, s.ID, s.BotAID, s.BotBID, nextGameNum, rng); err != nil {
+ log.Printf("series-scheduler: failed to schedule game %d for series %d: %v", nextGameNum, s.ID, err)
+ continue
+ }
+ log.Printf("series-scheduler: scheduled game %d for series %d (%s vs %s)", nextGameNum, s.ID, s.BotAID, s.BotBID)
+ }
+
+ return nil
+}
+
+// scheduleSeriesGame creates a match and job for one game in a series.
+func (m *Matchmaker) scheduleSeriesGame(ctx context.Context, seriesID int64, botAID, botBID string, gameNum int, rng *rand.Rand) error {
+ // Fetch bot endpoints and secrets
+ var endpointA, secretA, endpointB, secretB string
+ err := m.db.QueryRowContext(ctx,
+ `SELECT endpoint_url, shared_secret FROM bots WHERE bot_id = $1`, botAID).Scan(&endpointA, &secretA)
+ if err != nil {
+ return fmt.Errorf("fetch bot %s: %w", botAID, err)
+ }
+ err = m.db.QueryRowContext(ctx,
+ `SELECT endpoint_url, shared_secret FROM bots WHERE bot_id = $1`, botBID).Scan(&endpointB, &secretB)
+ if err != nil {
+ return fmt.Errorf("fetch bot %s: %w", botBID, err)
+ }
+
+ // Decrypt secrets
+ if m.cfg.EncryptionKey != "" {
+ if dec, err := decryptSecret(secretA, m.cfg.EncryptionKey); err == nil {
+ secretA = dec
+ }
+ if dec, err := decryptSecret(secretB, m.cfg.EncryptionKey); err == nil {
+ secretB = dec
+ }
+ }
+
+ matchID, err := generateID("m_", 8)
+ if err != nil {
+ return err
+ }
+ jobID, err := generateID("j_", 8)
+ if err != nil {
+ return err
+ }
+
+ mapSeed := rng.Int63()
+ mapID := fmt.Sprintf("map_%d", mapSeed%100000)
+
+ type botConfig struct {
+ BotID string `json:"bot_id"`
+ Endpoint string `json:"endpoint"`
+ Secret string `json:"secret"`
+ Slot int `json:"slot"`
+ }
+ type jobConfig struct {
+ MatchID string `json:"match_id"`
+ SeriesID int64 `json:"series_id,omitempty"`
+ GameNum int `json:"game_num,omitempty"`
+ MapSeed int64 `json:"map_seed"`
+ MaxTurns int `json:"max_turns"`
+ Rows int `json:"rows"`
+ Cols int `json:"cols"`
+ Bots []botConfig `json:"bots"`
+ }
+
+ config := jobConfig{
+ MatchID: matchID,
+ SeriesID: seriesID,
+ GameNum: gameNum,
+ MapSeed: mapSeed,
+ MaxTurns: 500,
+ Rows: 60,
+ Cols: 60,
+ Bots: []botConfig{
+ {BotID: botAID, Endpoint: endpointA, Secret: secretA, Slot: 0},
+ {BotID: botBID, Endpoint: endpointB, Secret: secretB, Slot: 1},
+ },
+ }
+ configJSON, _ := json.Marshal(config)
+
+ tx, err := m.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO matches (match_id, map_id, map_seed, status) VALUES ($1, $2, $3, 'pending')`,
+ matchID, mapID, mapSeed)
+ if err != nil {
+ return fmt.Errorf("insert match: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO match_participants (match_id, bot_id, player_slot) VALUES ($1, $2, 0), ($1, $3, 1)`,
+ matchID, botAID, botBID)
+ if err != nil {
+ return fmt.Errorf("insert participants: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO jobs (job_id, match_id, status, config_json) VALUES ($1, $2, 'pending', $3)`,
+ jobID, matchID, configJSON)
+ if err != nil {
+ return fmt.Errorf("insert job: %w", err)
+ }
+
+ // Create the series_games row
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO series_games (series_id, match_id, game_num, winner_id)
+ VALUES ($1, $2, $3, NULL)
+ `, seriesID, matchID, gameNum)
+ if err != nil {
+ return fmt.Errorf("insert series_game: %w", err)
+ }
+
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+
+ // Enqueue in Valkey
+ if err := m.rdb.LPush(ctx, valkeyJobQueue, jobID).Err(); err != nil {
+ return fmt.Errorf("valkey push: %w", err)
+ }
+
+ return nil
+}
+
+// autoCreateSeries creates best-of-5 series between top-20 active bots,
+// one per bot per day.
+func (m *Matchmaker) autoCreateSeries(ctx context.Context) error {
+ // Find top-20 active bots by rating
+ rows, err := m.db.QueryContext(ctx, `
+ SELECT bot_id FROM bots
+ WHERE status = 'active' AND evolved = false
+ ORDER BY rating_mu DESC
+ LIMIT 20
+ `)
+ if err != nil {
+ return fmt.Errorf("query top bots: %w", err)
+ }
+ defer rows.Close()
+
+ var topBots []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return err
+ }
+ topBots = append(topBots, id)
+ }
+
+ if len(topBots) < 2 {
+ return nil
+ }
+
+ rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+
+ for _, botID := range topBots {
+ // Check if this bot already has an active or pending series created today
+ var todaySeries int
+ err := m.db.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM series
+ WHERE (bot_a_id = $1 OR bot_b_id = $1)
+ AND created_at >= NOW() - INTERVAL '24 hours'
+ AND status IN ('active', 'pending')
+ `, botID).Scan(&todaySeries)
+ if err != nil || todaySeries > 0 {
+ continue
+ }
+
+ // Pick an opponent — closest rating that isn't this bot and doesn't have an active series
+ var opponentID string
+ err = m.db.QueryRowContext(ctx, `
+ SELECT b.bot_id FROM bots b
+ WHERE b.bot_id != $1
+ AND b.status = 'active'
+ AND NOT EXISTS (
+ SELECT 1 FROM series s
+ WHERE ((s.bot_a_id = $1 AND s.bot_b_id = b.bot_id)
+ OR (s.bot_a_id = b.bot_id AND s.bot_b_id = $1))
+ AND s.status IN ('active', 'pending')
+ )
+ ORDER BY ABS(b.rating_mu - (SELECT rating_mu FROM bots WHERE bot_id = $1)) ASC
+ LIMIT 1
+ `, botID).Scan(&opponentID)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ continue
+ }
+ return fmt.Errorf("find opponent for %s: %w", botID, err)
+ }
+
+ // Determine format based on ratings — closer ratings get longer series
+ var botRating, oppRating float64
+ err = m.db.QueryRowContext(ctx,
+ `SELECT rating_mu FROM bots WHERE bot_id = $1`, botID).Scan(&botRating)
+ if err != nil {
+ continue
+ }
+ err = m.db.QueryRowContext(ctx,
+ `SELECT rating_mu FROM bots WHERE bot_id = $1`, opponentID).Scan(&oppRating)
+ if err != nil {
+ continue
+ }
+
+ format := 5 // default best-of-5
+ ratingGap := botRating - oppRating
+ if ratingGap < 0 {
+ ratingGap = -ratingGap
+ }
+ if ratingGap < 50 {
+ format = 7 // close ratings → best-of-7
+ } else if ratingGap > 200 {
+ format = 3 // large gap → best-of-3
+ }
+
+ // Randomize who is bot_a vs bot_b
+ botAID, botBID := botID, opponentID
+ if rng.Intn(2) == 0 {
+ botAID, botBID = botBID, botAID
+ }
+
+ // Get the active season ID (if any)
+ var seasonID sql.NullInt64
+ m.db.QueryRowContext(ctx,
+ `SELECT id FROM seasons WHERE status = 'active' ORDER BY starts_at DESC LIMIT 1`).Scan(&seasonID)
+
+ _, err = m.db.ExecContext(ctx, `
+ INSERT INTO series (bot_a_id, bot_b_id, format, status, a_wins, b_wins, updated_at)
+ VALUES ($1, $2, $3, 'active', 0, 0, NOW())
+ `, botAID, botBID, format)
+ if err != nil {
+ log.Printf("series-scheduler: failed to create series (%s vs %s): %v", botAID, botBID, err)
+ continue
+ }
+ _ = seasonID // use in future season-series linking
+ log.Printf("series-scheduler: created best-of-%d series: %s vs %s", format, botAID, botBID)
+ }
+
+ return nil
+}
+
+// tickSeasonReset checks for seasons that have ended and performs:
+// 1. Snapshot current ELO ratings into season_snapshots
+// 2. Apply decay formula to all bot ratings
+// 3. Close the old season and start a new one
+func (m *Matchmaker) tickSeasonReset(ctx context.Context) {
+ // Find active seasons that have passed their ends_at
+ rows, err := m.db.QueryContext(ctx, `
+ SELECT id, name, theme, rules_version FROM seasons
+ WHERE status = 'active' AND ends_at IS NOT NULL AND ends_at <= NOW()
+ `)
+ if err != nil {
+ log.Printf("season-reset: query error: %v", err)
+ return
+ }
+ defer rows.Close()
+
+ type endingSeason struct {
+ ID int64
+ Name string
+ Theme string
+ RulesVersion string
+ }
+ var ending []endingSeason
+
+ for rows.Next() {
+ var s endingSeason
+ var theme sql.NullString
+ if err := rows.Scan(&s.ID, &s.Name, &theme, &s.RulesVersion); err != nil {
+ log.Printf("season-reset: scan error: %v", err)
+ return
+ }
+ if theme.Valid {
+ s.Theme = theme.String
+ }
+ ending = append(ending, s)
+ }
+
+ for _, s := range ending {
+ if err := m.processSeasonEnd(ctx, s.ID, s.Name); err != nil {
+ log.Printf("season-reset: failed to process season %d (%s): %v", s.ID, s.Name, err)
+ continue
+ }
+ log.Printf("season-reset: processed season %d (%s) — snapshot + decay complete", s.ID, s.Name)
+ }
+
+ // Check if there's no active season and auto-start one
+ m.autoStartSeason(ctx)
+}
+
+// processSeasonEnd handles the end-of-season workflow for one season.
+func (m *Matchmaker) processSeasonEnd(ctx context.Context, seasonID int64, seasonName string) error {
+ tx, err := m.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // 1. Snapshot current ratings into season_snapshots
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO season_snapshots (season_id, bot_id, rank, rating, wins, losses)
+ SELECT $1, b.bot_id,
+ ROW_NUMBER() OVER (ORDER BY b.rating_mu DESC)::int,
+ b.rating_mu,
+ COALESCE(mp.wins, 0),
+ COALESCE(mp.losses, 0)
+ FROM bots b
+ LEFT JOIN (
+ SELECT bot_id,
+ COUNT(*) FILTER (WHERE player_slot = m.winner) AS wins,
+ COUNT(*) FILTER (WHERE player_slot != m.winner) AS losses
+ FROM match_participants mp
+ JOIN matches m ON mp.match_id = m.match_id
+ WHERE m.status = 'completed'
+ GROUP BY bot_id
+ ) mp ON mp.bot_id = b.bot_id
+ WHERE b.status != 'retired'
+ ORDER BY b.rating_mu DESC
+ `, seasonID)
+ if err != nil {
+ return fmt.Errorf("snapshot ratings: %w", err)
+ }
+
+ // 2. Determine champion (rank 1)
+ var championID string
+ err = tx.QueryRowContext(ctx, `
+ SELECT bot_id FROM season_snapshots
+ WHERE season_id = $1 AND rank = 1
+ `, seasonID).Scan(&championID)
+ if err != nil {
+ log.Printf("season-reset: could not determine champion for season %d: %v", seasonID, err)
+ }
+
+ // 3. Mark season as completed
+ _, err = tx.ExecContext(ctx, `
+ UPDATE seasons SET status = 'completed', champion_id = $1 WHERE id = $2
+ `, championID, seasonID)
+ if err != nil {
+ return fmt.Errorf("complete season: %w", err)
+ }
+
+ // 4. Apply decay to all non-retired bots
+ // Formula: new_mu = default + (current_mu - default) * decay_factor
+ // This pulls ratings toward 1500 but preserves relative ordering
+ decayFactor := m.cfg.SeasonDecayFactor
+ defaultMu := 1500.0
+ defaultPhi := 350.0
+ defaultSigma := 0.06
+
+ _, err = tx.ExecContext(ctx, `
+ UPDATE bots SET
+ rating_mu = $1 + (rating_mu - $1) * $2,
+ rating_phi = $3,
+ rating_sigma = $4
+ WHERE status != 'retired'
+ `, defaultMu, decayFactor, defaultPhi, defaultSigma)
+ if err != nil {
+ return fmt.Errorf("apply decay: %w", err)
+ }
+
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+
+ log.Printf("season-reset: season %d (%s) complete — champion=%s, decay=%.0f%%",
+ seasonID, seasonName, championID, decayFactor*100)
+
+ return nil
+}
+
+// autoStartSeason creates a new season if no active season exists.
+func (m *Matchmaker) autoStartSeason(ctx context.Context) {
+ var activeCount int
+ err := m.db.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM seasons WHERE status = 'active'`).Scan(&activeCount)
+ if err != nil || activeCount > 0 {
+ return
+ }
+
+ // Determine next season number
+ var maxNum int
+ err = m.db.QueryRowContext(ctx,
+ `SELECT COALESCE(MAX(id), 0) FROM seasons`).Scan(&maxNum)
+ if err != nil {
+ return
+ }
+
+ nextNum := maxNum + 1
+ seasonName := fmt.Sprintf("Season %d", nextNum)
+ themes := []string{"The Labyrinth", "Energy Rush", "Fog of War", "The Colosseum", "Shifting Sands"}
+ theme := themes[(nextNum-1)%len(themes)]
+ rulesVersion := fmt.Sprintf("%d.0", nextNum)
+
+ _, err = m.db.ExecContext(ctx, `
+ INSERT INTO seasons (name, theme, rules_version, status, starts_at, ends_at)
+ VALUES ($1, $2, $3, 'active', NOW(), NOW() + INTERVAL '28 days')
+ `, seasonName, theme, rulesVersion)
+ if err != nil {
+ log.Printf("season-reset: failed to create new season: %v", err)
+ return
+ }
+
+ log.Printf("season-reset: auto-started %s (%s) — ends in 28 days", seasonName, theme)
+}
diff --git a/cmd/acb-matchmaker/tickers.go b/cmd/acb-matchmaker/tickers.go
index 0d2d497..c595632 100644
--- a/cmd/acb-matchmaker/tickers.go
+++ b/cmd/acb-matchmaker/tickers.go
@@ -17,6 +17,8 @@ func (m *Matchmaker) StartTickers(ctx context.Context) {
go m.runTicker(ctx, "matchmaker", time.Duration(m.cfg.MatchmakerSecs)*time.Second, m.tickMatchmaker)
go m.runTicker(ctx, "health-checker", time.Duration(m.cfg.HealthCheckSecs)*time.Second, m.tickHealthChecker)
go m.runTicker(ctx, "stale-reaper", time.Duration(m.cfg.ReaperSecs)*time.Second, m.tickStaleReaper)
+ go m.runTicker(ctx, "series-scheduler", time.Duration(m.cfg.SeriesSchedSecs)*time.Second, m.tickSeriesScheduler)
+ go m.runTicker(ctx, "season-reset", time.Duration(m.cfg.SeasonResetSecs)*time.Second, m.tickSeasonReset)
}
func (m *Matchmaker) runTicker(ctx context.Context, name string, interval time.Duration, fn func(context.Context)) {
diff --git a/cmd/acb-worker/db.go b/cmd/acb-worker/db.go
index 7cd06b9..e24c1b4 100644
--- a/cmd/acb-worker/db.go
+++ b/cmd/acb-worker/db.go
@@ -6,6 +6,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
+ "log"
"time"
_ "github.com/lib/pq"
@@ -381,6 +382,12 @@ func (c *DBClient) SubmitMatchResult(ctx context.Context, jobID string, result *
}
}
+ // Resolve predictions for this match
+ if err := resolvePredictions(ctx, tx, matchID, result.WinnerID); err != nil {
+ // Log but don't fail the match result — predictions are non-critical
+ log.Printf("failed to resolve predictions for match %s: %v", matchID, err)
+ }
+
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
@@ -388,6 +395,84 @@ func (c *DBClient) SubmitMatchResult(ctx context.Context, jobID string, result *
return nil
}
+// resolvePredictions marks open predictions as correct/incorrect and updates predictor_stats.
+func resolvePredictions(ctx context.Context, tx *sql.Tx, matchID string, winnerBotID string) error {
+ if winnerBotID == "" {
+ // Draw or no winner — mark all open predictions as incorrect
+ _, err := tx.ExecContext(ctx, `
+ UPDATE predictions
+ SET correct = false, resolved_at = NOW()
+ WHERE match_id = $1 AND correct IS NULL
+ `, matchID)
+ if err != nil {
+ return fmt.Errorf("failed to resolve predictions (draw): %w", err)
+ }
+ } else {
+ // Mark predictions correct where predicted_bot matches winner, incorrect otherwise
+ _, err := tx.ExecContext(ctx, `
+ UPDATE predictions
+ SET correct = (predicted_bot = $1), resolved_at = NOW()
+ WHERE match_id = $2 AND correct IS NULL
+ `, winnerBotID, matchID)
+ if err != nil {
+ return fmt.Errorf("failed to resolve predictions: %w", err)
+ }
+ }
+
+ // Update predictor_stats for each predictor who had a prediction on this match
+ rows, err := tx.QueryContext(ctx, `
+ SELECT predictor_id, correct
+ FROM predictions
+ WHERE match_id = $1 AND resolved_at IS NOT NULL
+ `, matchID)
+ if err != nil {
+ return fmt.Errorf("failed to fetch resolved predictions: %w", err)
+ }
+ defer rows.Close()
+
+ type predResult struct {
+ PredictorID string
+ Correct bool
+ }
+ var results []predResult
+ for rows.Next() {
+ var r predResult
+ if err := rows.Scan(&r.PredictorID, &r.Correct); err != nil {
+ return fmt.Errorf("failed to scan prediction: %w", err)
+ }
+ results = append(results, r)
+ }
+
+ // Upsert predictor_stats for each predictor
+ for _, r := range results {
+ if r.Correct {
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO predictor_stats (predictor_id, correct, streak, best_streak, updated_at)
+ VALUES ($1, 1, 1, 1, NOW())
+ ON CONFLICT (predictor_id) DO UPDATE SET
+ correct = predictor_stats.correct + 1,
+ streak = predictor_stats.streak + 1,
+ best_streak = GREATEST(predictor_stats.best_streak, predictor_stats.streak + 1),
+ updated_at = NOW()
+ `, r.PredictorID)
+ } else {
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO predictor_stats (predictor_id, incorrect, streak, best_streak, updated_at)
+ VALUES ($1, 1, 0, 0, NOW())
+ ON CONFLICT (predictor_id) DO UPDATE SET
+ incorrect = predictor_stats.incorrect + 1,
+ streak = 0,
+ updated_at = NOW()
+ `, r.PredictorID)
+ }
+ if err != nil {
+ return fmt.Errorf("failed to update predictor_stats for %s: %w", r.PredictorID, err)
+ }
+ }
+
+ return nil
+}
+
// FailJob marks a job as failed.
func (c *DBClient) FailJob(ctx context.Context, jobID string, workerID string, errorMessage string) error {
result, err := c.db.ExecContext(ctx, `
diff --git a/web/src/api-types.ts b/web/src/api-types.ts
index 25038cf..e701aa7 100644
--- a/web/src/api-types.ts
+++ b/web/src/api-types.ts
@@ -99,7 +99,17 @@ export interface RegisterResponse {
}
// Evolution dashboard types
+
+// Dashboard island stat (live.json format)
export interface IslandStat {
+ population: number;
+ best_rating: number;
+ best_bot: string;
+ language_div?: string;
+}
+
+// Full island stat (legacy format)
+export interface IslandStatFull {
count: number;
best_fitness: number;
avg_fitness: number;
@@ -107,6 +117,82 @@ export interface IslandStat {
promoted_count: number;
}
+// Parent info for candidate
+export interface ParentInfo {
+ id: string;
+ rating: number;
+}
+
+// Validation stage result
+export interface StageResult {
+ passed: boolean;
+ time_ms: number;
+ error?: string;
+}
+
+// Validation status
+export interface ValidationStatus {
+ syntax?: StageResult;
+ schema?: StageResult;
+ smoke?: StageResult;
+}
+
+// Match result in evaluation
+export interface MatchResult {
+ opponent: string;
+ won: boolean;
+ score: string;
+}
+
+// Evaluation status
+export interface EvaluationStatus {
+ matches_total: number;
+ matches_played: number;
+ results: MatchResult[];
+}
+
+// Current candidate being evaluated
+export interface Candidate {
+ id: string;
+ island: string;
+ language: string;
+ parents: ParentInfo[];
+ validation?: ValidationStatus;
+ evaluation?: EvaluationStatus;
+}
+
+// Current cycle info
+export interface CycleInfo {
+ generation: number;
+ started_at: string;
+ phase: string; // generating, validating, evaluating, promoting, idle
+ candidate?: Candidate;
+}
+
+// Activity entry in recent activity feed
+export interface ActivityEntry {
+ time: string;
+ generation: number;
+ candidate: string;
+ island: string;
+ result: string; // promoted, rejected
+ reason: string;
+ stage: string; // validation, promotion, deployment
+ bot_id?: string;
+ initial_rating?: number;
+}
+
+// Overall evolution statistics
+export interface Totals {
+ generations_total: number;
+ candidates_today: number;
+ promoted_today: number;
+ promotion_rate_7d: number;
+ highest_evolved_rating: number;
+ evolved_in_top_10: number;
+}
+
+// Legacy generation entry
export interface GenerationEntry {
generation: number;
island: string;
@@ -117,6 +203,7 @@ export interface GenerationEntry {
avg_fitness: number;
}
+// Legacy lineage node
export interface LineageNode {
id: number;
parent_ids: number[];
@@ -128,20 +215,26 @@ export interface LineageNode {
created_at: string;
}
+// Legacy meta snapshot
export interface MetaSnapshot {
generation: number;
island_counts: Record Last updated: ${formatTimestamp(data.updated_at)} ·
- ${data.total_programs} programs · ${data.promoted_count} promoted
No recent activity.
'; + return; + } + + const rows = activities.map(a => { + const resultClass = a.result === 'promoted' ? 'result-promoted' : 'result-rejected'; + const resultIcon = a.result === 'promoted' ? '🟢' : '🔴'; + const color = ISLAND_COLORS[a.island] || '#94a3b8'; + + return ` +Top predictors and their accuracy stats
+Predict match outcomes and climb the leaderboard
Log in to track your predictions
- +Make your first prediction above to start tracking stats