feat(evolver): expand MAP-Elites from 2-D to 4-D grid per §10.2

- Add Exploration and Formation axis definitions with feature extraction
  from source code pattern matching (exploration/formation indicators)
- Extend Grid key from (x,y) to (x,y,z,w) with 3⁴=81-cell behavior grid
- Update bin assignment, promotion gate, and persistence (JSON snapshot)
- Add Slice() for 2-D dashboard visualization across any axis pair
- Migration: old 2-D archives project at z=middle, w=middle
- Update cross-pollination to pad 2-element behavior vectors to 4
- Add Prometheus metrics to matchmaker (bot crashes, stale job count)
- Add rivalry detection to index builder (data/meta/rivalries.json)
- Web: batched bot list loading, leaderboard keyboard accessibility,
  improved ARIA attributes on match/playlist cards

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 15:44:39 -04:00
parent 4a92539c6f
commit 80334c6e34
15 changed files with 1433 additions and 152 deletions

View file

@ -202,7 +202,7 @@ func TestGate_RejectedWhenNicheOccupiedByFitterBot(t *testing.T) {
grid := mapelites.New(10)
// Pre-occupy the [5,5] cell with a very fit bot.
grid.TryPlace(99, 0.99, 0.5, 0.5)
grid.TryPlace(99, 0.99, 0.5, 0.5, 0.5, 0.5)
cfg := DefaultGateConfig()
gate := NewGate(cfg, grid)
@ -226,7 +226,7 @@ func TestGate_PromotedWhenOutperformsNicheChampion(t *testing.T) {
grid := mapelites.New(10)
// Pre-occupy with a weaker bot.
grid.TryPlace(99, 0.4, 0.5, 0.5)
grid.TryPlace(99, 0.4, 0.5, 0.5, 0.5, 0.5)
cfg := DefaultGateConfig()
gate := NewGate(cfg, grid)

View file

@ -54,7 +54,7 @@ type GateResult struct {
// (as opposed to simply filling an empty niche).
MapElitesImproved bool
// Placement is the (X, Y) grid cell the candidate occupies.
// Placement is the 4-D grid cell the candidate occupies.
Placement mapelites.Placement
// Reason is a human-readable explanation of the promotion decision.
@ -77,8 +77,8 @@ func NewGate(cfg GateConfig, grid *mapelites.Grid) *Gate {
// Evaluate applies the two-part promotion gate to the arena result.
//
// programID and fitness are the candidate's identifiers in the programs table.
// behaviorVec is [aggression, economy] ∈ [0,1]²; defaults to [0.5, 0.5] when
// nil or short.
// behaviorVec is [aggression, economy, exploration, formation] ∈ [0,1]⁴;
// defaults to [0.5, 0.5, 0.5, 0.5] when nil or short.
//
// Side effect: g.grid.TryPlace is called — the cell is updated when the
// candidate wins its behavioral niche.
@ -86,17 +86,19 @@ func (g *Gate) Evaluate(result *Result, programID int64, fitness float64, behavi
wr := ComputeFromResult(result)
nash := ComputeNash(result.WinRateVec)
agg, eco := 0.5, 0.5
if len(behaviorVec) >= 2 {
agg, eco = behaviorVec[0], behaviorVec[1]
// Default behavior: all dimensions at 0.5 (center of grid)
dims := [4]float64{0.5, 0.5, 0.5, 0.5}
for i := 0; i < len(behaviorVec) && i < 4; i++ {
dims[i] = behaviorVec[i]
}
agg, eco, expl, form := dims[0], dims[1], dims[2], dims[3]
// Sample the cell state before TryPlace so we can distinguish
// "fills empty niche" from "beats existing champion".
cellX, cellY := g.grid.BehaviorToCell(agg, eco)
priorCell := g.grid.Get(cellX, cellY)
cellX, cellY, cellZ, cellW := g.grid.BehaviorToCell(agg, eco, expl, form)
priorCell := g.grid.Get(cellX, cellY, cellZ, cellW)
placement, placed := g.grid.TryPlace(programID, fitness, agg, eco)
placement, placed := g.grid.TryPlace(programID, fitness, agg, eco, expl, form)
gr := &GateResult{
Nash: nash,
@ -114,16 +116,16 @@ func (g *Gate) Evaluate(result *Result, programID int64, fitness float64, behavi
gr.Promoted = true
if !priorCell.Occupied {
gr.Reason = fmt.Sprintf(
"promoted: Nash=%.3f ≥ %.3f, WR=%.3f (95%% CI %.3f%.3f), fills new niche [%d,%d]",
"promoted: Nash=%.3f ≥ %.3f, WR=%.3f (95%% CI %.3f%.3f), fills new niche [%d,%d,%d,%d]",
nash.NashValue, g.cfg.NashThreshold,
wr.Rate, wr.Lower, wr.Upper,
placement.X, placement.Y)
placement.X, placement.Y, placement.Z, placement.W)
} else {
gr.Reason = fmt.Sprintf(
"promoted: Nash=%.3f ≥ %.3f, WR=%.3f (95%% CI %.3f%.3f), beats niche [%d,%d] champion (%.3f→%.3f)",
"promoted: Nash=%.3f ≥ %.3f, WR=%.3f (95%% CI %.3f%.3f), beats niche [%d,%d,%d,%d] champion (%.3f→%.3f)",
nash.NashValue, g.cfg.NashThreshold,
wr.Rate, wr.Lower, wr.Upper,
placement.X, placement.Y, priorCell.Fitness, fitness)
placement.X, placement.Y, placement.Z, placement.W, priorCell.Fitness, fitness)
}
return gr
}
@ -136,8 +138,8 @@ func (g *Gate) Evaluate(result *Result, programID int64, fitness float64, behavi
why = append(why, fmt.Sprintf("WR CI lower=%.3f < %.3f", wr.Lower, g.cfg.WinRateLowerBound))
}
if !mapOK {
why = append(why, fmt.Sprintf("niche [%d,%d] occupied by fitter bot (fitness=%.3f)",
placement.X, placement.Y, priorCell.Fitness))
why = append(why, fmt.Sprintf("niche [%d,%d,%d,%d] occupied by fitter bot (fitness=%.3f)",
placement.X, placement.Y, placement.Z, placement.W, priorCell.Fitness))
}
gr.Reason = "rejected: " + strings.Join(why, "; ")
return gr

View file

@ -132,10 +132,7 @@ func (c *Checker) pollinateIsland(ctx context.Context, sourceIsland string, verb
}
// Copy the program to the target island (same generation, new entry).
behaviorVec := top.BehaviorVector
if len(behaviorVec) < 2 {
behaviorVec = []float64{0.5, 0.5}
}
behaviorVec := padBehaviorVec(top.BehaviorVector)
newID, err := c.store.Create(ctx, &evolverdb.Program{
Code: code,
@ -217,3 +214,16 @@ Source code in %s:
Return ONLY the translated %s code in a single fenced code block:`, fromLang, toLang, toLang, fromLang, code, toLang)
}
// padBehaviorVec ensures a behavior vector has at least 4 elements,
// padding with 0.5 for missing dimensions.
func padBehaviorVec(v []float64) []float64 {
out := make([]float64, 4)
for i := range out {
out[i] = 0.5
}
for i := 0; i < len(v) && i < 4; i++ {
out[i] = v[i]
}
return out
}

View file

@ -1,21 +1,26 @@
// Package mapelites implements a 2-D MAP-Elites behavior grid for diversity
// maintenance in the evolution pipeline.
// Package mapelites implements a 4-D MAP-Elites behavior grid for diversity
// maintenance in the evolution pipeline (plan §10.2).
//
// The two behavior dimensions are:
// The four behavior dimensions are:
//
// X axis aggression (0.0 = pacifist … 1.0 = full aggressor)
// Y axis economy (0.0 = ignores energy … 1.0 = perfect economy)
// X axis Aggression (0.0 = pacifist … 1.0 = full aggressor)
// Y axis Economy (0.0 = ignores energy … 1.0 = perfect economy)
// Z axis Exploration (0.0 = stays near core … 1.0 = covers >80% of map)
// W axis Formation (0.0 = units scattered … 1.0 = units always grouped)
//
// Each cell in the Size×Size grid holds the ID and fitness of the single best
// program discovered in that behavioral niche.
// Each dimension is binned into Size levels, producing Size⁴ cells.
// Plan §10.2 specifies Size=3 → 3⁴ = 81 cells.
package mapelites
import "math"
// Grid is a 2-D MAP-Elites behavior grid.
// NumDims is the number of behavior dimensions.
const NumDims = 4
// Grid is a 4-D MAP-Elites behavior grid.
type Grid struct {
size int
cells [][]Cell
cells map[[NumDims]int]Cell
}
// Cell is a single niche in the grid.
@ -27,62 +32,65 @@ type Cell struct {
// Placement records which grid cell a program was placed into.
type Placement struct {
X, Y int
X, Y, Z, W int
}
// New creates an empty Grid with the given side length.
// Key returns the [4]int array key for this placement.
func (p Placement) Key() [NumDims]int {
return [NumDims]int{p.X, p.Y, p.Z, p.W}
}
// New creates an empty Grid with the given side length per dimension.
// Total cells = size⁴. Use size=3 for the 81-cell grid per §10.2.
func New(size int) *Grid {
cells := make([][]Cell, size)
for i := range cells {
cells[i] = make([]Cell, size)
}
return &Grid{size: size, cells: cells}
return &Grid{size: size, cells: make(map[[NumDims]int]Cell)}
}
// 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.
// Size returns the side length of the grid (per dimension).
func (g *Grid) Size() int {
return g.size
}
// TotalCells returns size⁴.
func (g *Grid) TotalCells() int {
return g.size * g.size * g.size * g.size
}
// dimBin converts a continuous [0, 1] value to a discrete bin in [0, size-1].
func (g *Grid) dimBin(v float64) int {
return int(math.Min(math.Floor(v*float64(g.size)), float64(g.size-1)))
}
// BehaviorToCell converts continuous behavior values (each in [0, 1]) to
// discrete grid coordinates clamped to [0, size-1].
func (g *Grid) BehaviorToCell(aggression, economy, exploration, formation float64) (x, y, z, w int) {
return g.dimBin(aggression), g.dimBin(economy), g.dimBin(exploration), g.dimBin(formation)
}
// 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, exploration, formation float64) (Placement, bool) {
x, y, z, w := g.BehaviorToCell(aggression, economy, exploration, formation)
key := [NumDims]int{x, y, z, w}
cell, exists := g.cells[key]
if !exists || fitness > cell.Fitness {
g.cells[key] = Cell{ProgramID: id, Fitness: fitness, Occupied: true}
return Placement{X: x, Y: y, Z: z, W: w}, true
}
return Placement{X: x, Y: y, Z: z, W: w}, false
}
// Get returns the cell at grid coordinates (x, y, z, w).
func (g *Grid) Get(x, y, z, w int) Cell {
return g.cells[[NumDims]int{x, y, z, w}]
}
// 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
return len(g.cells)
}
// Elite returns the cell with the highest fitness in the grid.
@ -90,12 +98,10 @@ func (g *Grid) OccupiedCount() int {
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
}
for _, c := range g.cells {
if c.Occupied && (!found || c.Fitness > best.Fitness) {
best = c
found = true
}
}
return best, found
@ -103,13 +109,73 @@ func (g *Grid) Elite() (Cell, bool) {
// 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)
}
out := make([]Cell, 0, len(g.cells))
for _, c := range g.cells {
if c.Occupied {
out = append(out, c)
}
}
return out
}
// Slice returns a 2-D snapshot of the grid by fixing two dimensions.
// For example, to view the aggression×economy plane at exploration=1, formation=1:
//
// slice := grid.Slice(2, 1, 3, 1) // fix dim 2 (z) to 1, dim 3 (w) to 1
//
// Returns a size×size grid of cells.
func (g *Grid) Slice(fixedDim1, fixedVal1, fixedDim2, fixedVal2 int) [][]Cell {
result := make([][]Cell, g.size)
for i := range result {
result[i] = make([]Cell, g.size)
}
// Determine the two free dimensions (the ones not fixed)
free := [2]int{-1, -1}
fi := 0
for d := 0; d < NumDims; d++ {
if d != fixedDim1 && d != fixedDim2 {
free[fi] = d
fi++
}
}
for key, cell := range g.cells {
if key[fixedDim1] == fixedVal1 && key[fixedDim2] == fixedVal2 {
result[key[free[0]]][key[free[1]]] = cell
}
}
return result
}
// GridSnapshot is a JSON-serializable snapshot of the grid for the dashboard.
type GridSnapshot struct {
Size int `json:"size"`
DimNames [NumDims]string `json:"dim_names"`
Cells []CellSnapshot `json:"cells"`
}
// CellSnapshot is one occupied cell in the grid snapshot.
type CellSnapshot struct {
Pos [NumDims]int `json:"pos"`
Program int64 `json:"program_id"`
Fitness float64 `json:"fitness"`
}
// Snapshot returns a JSON-serializable representation of the grid.
func (g *Grid) Snapshot() GridSnapshot {
snap := GridSnapshot{
Size: g.size,
DimNames: [NumDims]string{"aggression", "economy", "exploration", "formation"},
}
for key, cell := range g.cells {
if cell.Occupied {
snap.Cells = append(snap.Cells, CellSnapshot{
Pos: key,
Program: cell.ProgramID,
Fitness: math.Round(cell.Fitness*1000) / 1000,
})
}
}
return snap
}

View file

@ -3,101 +3,113 @@ package mapelites
import "testing"
func TestBehaviorToCell(t *testing.T) {
g := New(10)
// 3×3×3×3 grid per §10.2
g := New(3)
cases := []struct {
agg, eco float64
wantX, wantY int
agg, eco, expl, form float64
wantX, wantY, wantZ, wantW 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},
{0.0, 0.0, 0.0, 0.0, 0, 0, 0, 0},
{1.0, 1.0, 1.0, 1.0, 2, 2, 2, 2},
{0.5, 0.5, 0.5, 0.5, 1, 1, 1, 1},
{0.15, 0.85, 0.33, 0.66, 0, 2, 0, 1},
{0.99, 0.01, 0.99, 0.01, 2, 0, 2, 0},
{0.09, 0.09, 0.09, 0.09, 0, 0, 0, 0},
{0.34, 0.67, 0.34, 0.67, 1, 2, 1, 2},
}
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)
x, y, z, w := g.BehaviorToCell(tc.agg, tc.eco, tc.expl, tc.form)
if x != tc.wantX || y != tc.wantY || z != tc.wantZ || w != tc.wantW {
t.Errorf("BehaviorToCell(%.2f, %.2f, %.2f, %.2f) = (%d,%d,%d,%d), want (%d,%d,%d,%d)",
tc.agg, tc.eco, tc.expl, tc.form, x, y, z, w, tc.wantX, tc.wantY, tc.wantZ, tc.wantW)
}
}
}
func TestTotalCells(t *testing.T) {
g := New(3)
if g.TotalCells() != 81 {
t.Errorf("3⁴ = 81 cells, got %d", g.TotalCells())
}
g10 := New(10)
if g10.TotalCells() != 10000 {
t.Errorf("10⁴ = 10000 cells, got %d", g10.TotalCells())
}
}
func TestTryPlace_EmptyCell(t *testing.T) {
g := New(10)
p, placed := g.TryPlace(1, 10.0, 0.1, 0.9)
g := New(3)
p, placed := g.TryPlace(1, 10.0, 0.1, 0.9, 0.5, 0.5)
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)
if p.X != 0 || p.Y != 2 || p.Z != 1 || p.W != 1 {
t.Errorf("expected cell (0,2,1,1), got (%d,%d,%d,%d)", p.X, p.Y, p.Z, p.W)
}
cell := g.Get(1, 9)
cell := g.Get(0, 2, 1, 1)
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)
g := New(3)
g.TryPlace(1, 10.0, 0.5, 0.5, 0.5, 0.5)
_, placed := g.TryPlace(2, 5.0, 0.5, 0.5)
_, placed := g.TryPlace(2, 5.0, 0.5, 0.5, 0.5, 0.5)
if placed {
t.Fatal("lower fitness should not replace incumbent")
}
if g.Get(5, 5).ProgramID != 1 {
if g.Get(1, 1, 1, 1).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)
g := New(3)
g.TryPlace(1, 10.0, 0.5, 0.5, 0.5, 0.5)
_, placed := g.TryPlace(2, 20.0, 0.5, 0.5)
_, placed := g.TryPlace(2, 20.0, 0.5, 0.5, 0.5, 0.5)
if !placed {
t.Fatal("higher fitness should replace incumbent")
}
cell := g.Get(5, 5)
cell := g.Get(1, 1, 1, 1)
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)
g := New(3)
g.TryPlace(1, 10.0, 0.5, 0.5, 0.5, 0.5)
_, placed := g.TryPlace(2, 10.0, 0.5, 0.5, 0.5, 0.5)
if placed {
t.Fatal("equal fitness should not replace incumbent")
}
}
func TestOccupiedCount(t *testing.T) {
g := New(10)
g := New(3)
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)
g.TryPlace(1, 1.0, 0.1, 0.1, 0.1, 0.1)
g.TryPlace(2, 1.0, 0.9, 0.9, 0.9, 0.9)
g.TryPlace(3, 1.0, 0.5, 0.5, 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)
g.TryPlace(4, 99.0, 0.5, 0.5, 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)
g := New(3)
_, found := g.Elite()
if found {
t.Fatal("empty grid should have no elite")
@ -105,10 +117,10 @@ func TestElite_EmptyGrid(t *testing.T) {
}
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)
g := New(3)
g.TryPlace(1, 5.0, 0.1, 0.1, 0.1, 0.1)
g.TryPlace(2, 15.0, 0.9, 0.9, 0.9, 0.9)
g.TryPlace(3, 10.0, 0.5, 0.5, 0.5, 0.5)
elite, found := g.Elite()
if !found {
@ -120,14 +132,14 @@ func TestElite(t *testing.T) {
}
func TestAllElites(t *testing.T) {
g := New(10)
g := New(3)
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)
g.TryPlace(1, 1.0, 0.0, 0.0, 0.0, 0.0)
g.TryPlace(2, 2.0, 0.5, 0.5, 0.5, 0.5)
g.TryPlace(3, 3.0, 1.0, 1.0, 1.0, 1.0)
elites := g.AllElites()
if len(elites) != 3 {
@ -136,26 +148,26 @@ func TestAllElites(t *testing.T) {
}
func TestSeedBehaviorVectors(t *testing.T) {
// Verify that the 6 seed bots land in distinct grid cells on a 10x10 grid.
g := New(10)
// Verify that the 6 seed bots land in distinct grid cells on a 3×3×3×3 grid.
g := New(3)
bots := []struct {
id int64
name string
aggression float64
economy float64
id int64
name string
aggression, economy float64
exploration, formation 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},
{1, "gatherer", 0.1, 0.9, 0.3, 0.2},
{2, "guardian", 0.2, 0.6, 0.1, 0.8},
{3, "rusher", 0.9, 0.2, 0.5, 0.3},
{4, "swarm", 0.6, 0.5, 0.4, 0.9},
{5, "hunter", 0.7, 0.3, 0.8, 0.4},
{6, "random", 0.3, 0.4, 0.5, 0.5},
}
placed := 0
for _, b := range bots {
_, ok := g.TryPlace(b.id, 1.0, b.aggression, b.economy)
_, ok := g.TryPlace(b.id, 1.0, b.aggression, b.economy, b.exploration, b.formation)
if ok {
placed++
}
@ -168,3 +180,93 @@ func TestSeedBehaviorVectors(t *testing.T) {
t.Errorf("expected 6 occupied cells, got %d", g.OccupiedCount())
}
}
func TestSlice(t *testing.T) {
g := New(3)
// Place bots at known positions
g.TryPlace(1, 5.0, 0.1, 0.1, 0.1, 0.1) // (0,0,0,0)
g.TryPlace(2, 8.0, 0.9, 0.9, 0.1, 0.1) // (2,2,0,0)
g.TryPlace(3, 3.0, 0.5, 0.1, 0.1, 0.1) // (1,0,0,0)
// Slice: aggression×economy at z=0, w=0 (dims 2,3 fixed to 0)
slice := g.Slice(2, 0, 3, 0)
if len(slice) != 3 {
t.Fatalf("expected 3 rows in slice, got %d", len(slice))
}
if len(slice[0]) != 3 {
t.Fatalf("expected 3 cols in slice, got %d", len(slice[0]))
}
// Check (0,0) = program 1
c00 := slice[0][0]
if !c00.Occupied || c00.ProgramID != 1 {
t.Errorf("slice[0][0]: expected program 1, got %+v", c00)
}
// Check (2,2) = program 2
c22 := slice[2][2]
if !c22.Occupied || c22.ProgramID != 2 {
t.Errorf("slice[2][2]: expected program 2, got %+v", c22)
}
// Check (1,0) = program 3
c10 := slice[1][0]
if !c10.Occupied || c10.ProgramID != 3 {
t.Errorf("slice[1][0]: expected program 3, got %+v", c10)
}
}
func TestSnapshot(t *testing.T) {
g := New(3)
g.TryPlace(1, 5.0, 0.1, 0.1, 0.1, 0.1)
g.TryPlace(2, 8.0, 0.9, 0.9, 0.9, 0.9)
snap := g.Snapshot()
if snap.Size != 3 {
t.Errorf("expected size 3, got %d", snap.Size)
}
if len(snap.Cells) != 2 {
t.Errorf("expected 2 cells in snapshot, got %d", len(snap.Cells))
}
if snap.DimNames[0] != "aggression" || snap.DimNames[3] != "formation" {
t.Errorf("dim names: %v", snap.DimNames)
}
}
func TestMigration_2Dto4D(t *testing.T) {
// Simulate migrating a 2-D archive into a 4-D grid.
// Old programs had only aggression and economy.
// They should project into the 4-D grid at z=middle, w=middle.
g := New(3)
// Old 2-D program: aggression=0.5, economy=0.5
// Migrate to 4-D: exploration=0.5 (middle), formation=0.5 (middle)
middle := 0.5
g.TryPlace(1, 10.0, 0.5, 0.5, middle, middle)
cell := g.Get(1, 1, 1, 1)
if !cell.Occupied {
t.Error("migrated program should occupy (1,1,1,1)")
}
if cell.ProgramID != 1 {
t.Errorf("expected program 1, got %d", cell.ProgramID)
}
// A new 4-D program with different exploration/formation should go to a different cell
_, placed := g.TryPlace(2, 15.0, 0.5, 0.5, 0.9, 0.1)
if !placed {
t.Error("different 4-D coords should be a new cell")
}
cell2 := g.Get(1, 1, 2, 0)
if !cell2.Occupied || cell2.ProgramID != 2 {
t.Error("new program should be at (1,1,2,0)")
}
}
func TestPlacementKey(t *testing.T) {
p := Placement{X: 1, Y: 2, Z: 0, W: 2}
key := p.Key()
if key != [NumDims]int{1, 2, 0, 2} {
t.Errorf("expected [1 2 0 2], got %v", key)
}
}

View file

@ -369,7 +369,11 @@ func runEvaluate(ctx context.Context, db *sql.DB, args []string) {
if promoted, err := store.ListPromoted(ctx); err == nil {
for _, pp := range promoted {
if len(pp.BehaviorVector) >= 2 {
grid.TryPlace(pp.ProgramID, pp.Fitness, pp.BehaviorVector[0], pp.BehaviorVector[1])
expl, form := 0.5, 0.5
if len(pp.BehaviorVector) >= 4 {
expl, form = pp.BehaviorVector[2], pp.BehaviorVector[3]
}
grid.TryPlace(pp.ProgramID, pp.Fitness, pp.BehaviorVector[0], pp.BehaviorVector[1], expl, form)
}
}
}

View file

@ -488,7 +488,11 @@ func runCycle(ctx context.Context, db *sql.DB, store *evolverdb.Store,
promotedPrograms, _ := store.ListPromoted(ctx)
for _, pp := range promotedPrograms {
if len(pp.BehaviorVector) >= 2 {
grid.TryPlace(pp.ProgramID, pp.Fitness, pp.BehaviorVector[0], pp.BehaviorVector[1])
expl, form := 0.5, 0.5
if len(pp.BehaviorVector) >= 4 {
expl, form = pp.BehaviorVector[2], pp.BehaviorVector[3]
}
grid.TryPlace(pp.ProgramID, pp.Fitness, pp.BehaviorVector[0], pp.BehaviorVector[1], expl, form)
}
}
@ -556,11 +560,13 @@ func runCycle(ctx context.Context, db *sql.DB, store *evolverdb.Store,
return true, nil
}
// estimateBehaviorVector analyzes code to estimate aggression/economy behavior.
// estimateBehaviorVector analyzes code to estimate aggression/economy/exploration/formation behavior.
func estimateBehaviorVector(code, lang string) []float64 {
// Default to balanced behavior
aggression := 0.5
economy := 0.5
exploration := 0.5
formation := 0.5
codeLower := strings.ToLower(code)
@ -594,23 +600,49 @@ func estimateBehaviorVector(code, lang string) []float64 {
defensiveCount += strings.Count(codeLower, p)
}
// Exploration indicators
explorationPatterns := []string{
"explore", "scout", "scan", "discover", "map", "bfs", "visibility",
"vision", "uncover", "spread",
}
explorationCount := 0
for _, p := range explorationPatterns {
explorationCount += strings.Count(codeLower, p)
}
// Formation indicators
formationPatterns := []string{
"formation", "group", "cluster", "cohesion", "together", "swarm",
"center_of_mass", "rally", "merge", "assemble",
}
formationCount := 0
for _, p := range formationPatterns {
formationCount += strings.Count(codeLower, p)
}
// Normalize and adjust behavior vector
total := aggressiveCount + economyCount + defensiveCount
if total > 0 {
aggression = float64(aggressiveCount) / float64(total)
// Economy is relative to energy/gather focus vs combat
economy = float64(economyCount) / float64(total+1)
// Adjust aggression based on defensive patterns
if defensiveCount > aggressiveCount {
aggression = aggression * 0.5 // reduce aggression for defensive bots
aggression = aggression * 0.5
}
}
// Exploration and formation have independent scaling
if explorationCount > 0 {
exploration = clamp(float64(explorationCount)/10.0, 0.1, 0.9)
}
if formationCount > 0 {
formation = clamp(float64(formationCount)/10.0, 0.1, 0.9)
}
// Clamp to [0.1, 0.9] to avoid edge cases
aggression = clamp(aggression, 0.1, 0.9)
economy = clamp(economy, 0.1, 0.9)
return []float64{aggression, economy}
return []float64{aggression, economy, exploration, formation}
}
func clamp(v, min, max float64) float64 {

View file

@ -5,8 +5,10 @@ import (
"database/sql"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
@ -133,6 +135,12 @@ func generateAllIndexes(data *IndexData, outputDir string, db *sql.DB) error {
return fmt.Errorf("predictions index: %w", err)
}
// Generate rivalries (data/meta/rivalries.json)
rivalries := computeRivalries(data, botNameMap)
if err := generateRivalriesIndex(rivalries, outputDir); err != nil {
return fmt.Errorf("rivalries index: %w", err)
}
// Generate playlists
if err := generatePlaylists(data, outputDir, botNameMap); err != nil {
return fmt.Errorf("playlists: %w", err)
@ -985,7 +993,7 @@ func minScoreDiff(m MatchData) int {
return minDiff
}
func sortSlice(s []MatchData, less func(i, j int) bool) {
func sortSlice[T any](s []T, less func(i, j int) bool) {
for i := 0; i < len(s)-1; i++ {
for j := i + 1; j < len(s); j++ {
if less(j, i) {
@ -1210,6 +1218,288 @@ func isRivalryMatch(m MatchData, data *IndexData) bool {
return count >= 3
}
// ─── Rivalry Detection (§13.5) ─────────────────────────────────────────────────
const (
rivalryMinMatches = 10 // minimum h2h matches to qualify
rivalryTopK = 20 // max rivalries to emit
rivalryRecencyDecay = 0.95 // per-day decay for recency weighting
)
// RivalryEntry represents a detected rivalry pair for data/meta/rivalries.json.
type RivalryEntry struct {
BotA RivalryBot `json:"bot_a"`
BotB RivalryBot `json:"bot_b"`
TotalMatches int `json:"matches"`
Record RivalryRecord `json:"record"`
ClosestMatch string `json:"closest_match,omitempty"`
LongestStreak *RivalryStreak `json:"longest_streak,omitempty"`
RecentMatches []string `json:"recent_matches"`
Narrative string `json:"narrative"`
Score float64 `json:"score"`
}
type RivalryBot struct {
ID string `json:"id"`
Name string `json:"name"`
}
type RivalryRecord struct {
AWins int `json:"a_wins"`
BWins int `json:"b_wins"`
Draws int `json:"draws"`
}
type RivalryStreak struct {
Holder string `json:"holder"`
Length int `json:"length"`
}
// RivalriesIndex is the top-level structure for data/meta/rivalries.json.
type RivalriesIndex struct {
UpdatedAt string `json:"updated_at"`
Rivalries []RivalryEntry `json:"rivalries"`
}
// pairKey returns a canonical key for a bot pair (alphabetically ordered).
func pairKey(a, b string) string {
if a > b {
a, b = b, a
}
return a + ":" + b
}
type h2hRecord struct {
botAID, botBID string
aWins, bWins int
draws int
matchDates []time.Time
matchIDs []string
scoreDiffs []int
winnerSeq []string // bot_id of winner per match ("draw" for draws)
}
// computeRivalries builds the h2h matrix from all matches, scores each pair
// by win-rate balance × recency × total matches, and returns the top K.
func computeRivalries(data *IndexData, botNameMap map[string]string) []RivalryEntry {
// Accumulate head-to-head records (only 2-player matches).
pairs := make(map[string]*h2hRecord)
for _, m := range data.Matches {
if len(m.Participants) != 2 {
continue
}
a, b := m.Participants[0].BotID, m.Participants[1].BotID
key := pairKey(a, b)
rec, ok := pairs[key]
if !ok {
// Canonical order: alphabetically first is bot A.
if a > b {
a, b = b, a
}
rec = &h2hRecord{botAID: a, botBID: b}
pairs[key] = rec
}
rec.matchIDs = append(rec.matchIDs, m.ID)
rec.matchDates = append(rec.matchDates, m.PlayedAt)
// Score diff for closest match detection.
if len(m.Participants) == 2 {
rec.scoreDiffs = append(rec.scoreDiffs, absInt(m.Participants[0].Score-m.Participants[1].Score))
}
switch {
case m.WinnerID == "":
rec.draws++
rec.winnerSeq = append(rec.winnerSeq, "draw")
case m.WinnerID == rec.botAID:
rec.aWins++
rec.winnerSeq = append(rec.winnerSeq, rec.botAID)
default:
rec.bWins++
rec.winnerSeq = append(rec.winnerSeq, rec.botBID)
}
}
// Score and rank.
now := data.GeneratedAt
var candidates []RivalryEntry
for _, rec := range pairs {
total := rec.aWins + rec.bWins + rec.draws
if total < rivalryMinMatches {
continue
}
// Win-rate balance: 1.0 for perfect 50/50, approaches 0 for dominant pairs.
balance := 1.0 - float64(absInt(rec.aWins-rec.bWins))/float64(total)
// Recency: weighted sum where recent matches count more.
var recencyScore float64
for _, d := range rec.matchDates {
daysAgo := now.Sub(d).Hours() / 24
if daysAgo < 0 {
daysAgo = 0
}
recencyScore += math.Pow(rivalryRecencyDecay, daysAgo)
}
// Normalise recency to [0, 1] relative to total matches.
recencyNorm := recencyScore / float64(total)
// Final score: balance × recency × log(total) for volume weighting.
score := balance * recencyNorm * math.Log(float64(total))
// Closest match: smallest score diff.
closestMatch := ""
if len(rec.scoreDiffs) > 0 {
minDiff := rec.scoreDiffs[0]
minIdx := 0
for i, d := range rec.scoreDiffs {
if d < minDiff {
minDiff = d
minIdx = i
}
}
closestMatch = rec.matchIDs[minIdx]
}
// Longest win streak.
streak := longestStreak(rec.winnerSeq, rec.botAID, rec.botBID)
// Recent match IDs (last 10).
recentCount := 10
if len(rec.matchIDs) < recentCount {
recentCount = len(rec.matchIDs)
}
recentMatches := make([]string, recentCount)
for i := 0; i < recentCount; i++ {
recentMatches[i] = rec.matchIDs[len(rec.matchIDs)-1-i]
}
aName := botNameMap[rec.botAID]
bName := botNameMap[rec.botBID]
candidates = append(candidates, RivalryEntry{
BotA: RivalryBot{ID: rec.botAID, Name: aName},
BotB: RivalryBot{ID: rec.botBID, Name: bName},
TotalMatches: total,
Record: RivalryRecord{AWins: rec.aWins, BWins: rec.bWins, Draws: rec.draws},
ClosestMatch: closestMatch,
LongestStreak: streak,
RecentMatches: recentMatches,
Narrative: buildRivalryNarrative(aName, bName, total, rec.aWins, rec.bWins, rec.draws, streak),
Score: score,
})
}
// Sort by score descending.
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].Score > candidates[j].Score
})
if len(candidates) > rivalryTopK {
candidates = candidates[:rivalryTopK]
}
return candidates
}
// longestStreak finds the longest consecutive win streak for either bot in the winner sequence.
func longestStreak(winners []string, botA, botB string) *RivalryStreak {
if len(winners) == 0 {
return nil
}
var bestHolder string
var bestLen int
var curHolder string
var curLen int
for _, w := range winners {
if w == "draw" {
curLen = 0
curHolder = ""
continue
}
if w == curHolder {
curLen++
} else {
curHolder = w
curLen = 1
}
if curLen > bestLen {
bestLen = curLen
bestHolder = curHolder
}
}
if bestLen < 2 {
return nil
}
return &RivalryStreak{Holder: bestHolder, Length: bestLen}
}
// buildRivalryNarrative generates a template-based narrative from rivalry stats.
func buildRivalryNarrative(aName, bName string, total, aWins, bWins, draws int, streak *RivalryStreak) string {
leading := aName
trailing := bName
leadWins := aWins
trailWins := bWins
if bWins > aWins {
leading, trailing = trailing, leading
leadWins, trailWins = trailWins, leadWins
}
switch {
case aWins == bWins:
return fmt.Sprintf("%s and %s have met %d times — the series is dead even at %d-%d%s. Every match shifts the balance.",
aName, bName, total, aWins, bWins, drawSuffix(draws))
case streak != nil && streak.Length >= 3:
return fmt.Sprintf("%s and %s have met %d times with %s holding a %d-%d edge. %s is currently on a %d-match winning streak.",
aName, bName, total, leading, leadWins, trailWins, streak.Holder, streak.Length)
default:
return fmt.Sprintf("%s and %s have met %d times — %s leads the series %d-%d%s. A rivalry defined by closely contested grid battles.",
aName, bName, total, leading, leadWins, trailWins, drawSuffix(draws))
}
}
func drawSuffix(draws int) string {
if draws == 0 {
return ""
}
return fmt.Sprintf(" (%d draw%s)", draws, pluralS(draws))
}
func pluralS(n int) string {
if n == 1 {
return ""
}
return "s"
}
func absInt(x int) int {
if x < 0 {
return -x
}
return x
}
// generateRivalriesIndex writes data/meta/rivalries.json.
func generateRivalriesIndex(rivalries []RivalryEntry, outputDir string) error {
metaDir := filepath.Join(outputDir, "data", "meta")
if err := os.MkdirAll(metaDir, 0755); err != nil {
return err
}
index := RivalriesIndex{
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
Rivalries: rivalries,
}
return writeJSON(filepath.Join(metaDir, "rivalries.json"), index)
}
func writeJSON(path string, data interface{}) error {
f, err := os.Create(path)
if err != nil {

View file

@ -0,0 +1,495 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
)
func TestComputeRivalries_BasicPair(t *testing.T) {
now := time.Date(2026, 4, 22, 12, 0, 0, 0, time.UTC)
// Create 12 matches between bot1 and bot2 (above min threshold of 10)
matches := make([]MatchData, 12)
for i := 0; i < 6; i++ {
matches[i] = MatchData{
ID: fmt.Sprintf("m_a_%d", i),
WinnerID: "bot1",
PlayedAt: now.Add(-time.Duration(12-i) * 24 * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 5 - i%3, Won: true},
{BotID: "bot2", Score: 2 + i%2, Won: false},
},
}
}
for i := 0; i < 6; i++ {
matches[6+i] = MatchData{
ID: fmt.Sprintf("m_b_%d", i),
WinnerID: "bot2",
PlayedAt: now.Add(-time.Duration(6-i) * 24 * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 2 + i%2, Won: false},
{BotID: "bot2", Score: 5 - i%3, Won: true},
},
}
}
data := &IndexData{
GeneratedAt: now,
Bots: []BotData{
{ID: "bot1", Name: "AlphaBot"},
{ID: "bot2", Name: "BetaBot"},
},
Matches: matches,
}
botNameMap := map[string]string{"bot1": "AlphaBot", "bot2": "BetaBot"}
rivalries := computeRivalries(data, botNameMap)
if len(rivalries) != 1 {
t.Fatalf("Expected 1 rivalry, got %d", len(rivalries))
}
r := rivalries[0]
if r.BotA.ID != "bot1" || r.BotB.ID != "bot2" {
t.Errorf("Bot IDs: got %q/%q, want bot1/bot2", r.BotA.ID, r.BotB.ID)
}
if r.TotalMatches != 12 {
t.Errorf("TotalMatches: got %d, want 12", r.TotalMatches)
}
if r.Record.AWins != 6 || r.Record.BWins != 6 {
t.Errorf("Record: got %d-%d, want 6-6", r.Record.AWins, r.Record.BWins)
}
if r.Score <= 0 {
t.Errorf("Score should be positive, got %f", r.Score)
}
if r.Narrative == "" {
t.Error("Narrative should not be empty")
}
if r.ClosestMatch == "" {
t.Error("ClosestMatch should not be empty")
}
if len(r.RecentMatches) > 10 {
t.Errorf("RecentMatches: got %d, want at most 10", len(r.RecentMatches))
}
}
func TestComputeRivalries_BelowThreshold(t *testing.T) {
now := time.Now()
// Only 5 matches — below rivalryMinMatches (10)
matches := make([]MatchData, 5)
for i := 0; i < 5; i++ {
matches[i] = MatchData{
ID: fmt.Sprintf("m_%d", i),
WinnerID: "bot1",
PlayedAt: now.Add(-time.Duration(i) * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true},
{BotID: "bot2", Score: 1, Won: false},
},
}
}
data := &IndexData{
GeneratedAt: now,
Matches: matches,
}
rivalries := computeRivalries(data, nil)
if len(rivalries) != 0 {
t.Errorf("Expected 0 rivalries below threshold, got %d", len(rivalries))
}
}
func TestComputeRivalries_ScoreRanking(t *testing.T) {
now := time.Now()
// Pair A: 20 matches, 10-10 split (perfect balance, high volume)
var matchesA []MatchData
for i := 0; i < 10; i++ {
matchesA = append(matchesA, MatchData{
ID: fmt.Sprintf("a_win_%d", i), WinnerID: "botA",
PlayedAt: now.Add(-time.Duration(i) * time.Hour),
Participants: []ParticipantData{
{BotID: "botA", Score: 3, Won: true},
{BotID: "botB", Score: 1, Won: false},
},
})
matchesA = append(matchesA, MatchData{
ID: fmt.Sprintf("b_win_%d", i), WinnerID: "botB",
PlayedAt: now.Add(-time.Duration(10+i) * time.Hour),
Participants: []ParticipantData{
{BotID: "botA", Score: 1, Won: false},
{BotID: "botB", Score: 3, Won: true},
},
})
}
// Pair C/D: 12 matches, 10-2 split (imbalanced, lower score)
var matchesCD []MatchData
for i := 0; i < 10; i++ {
matchesCD = append(matchesCD, MatchData{
ID: fmt.Sprintf("cd_a_%d", i), WinnerID: "botC",
PlayedAt: now.Add(-time.Duration(i) * time.Hour),
Participants: []ParticipantData{
{BotID: "botC", Score: 5, Won: true},
{BotID: "botD", Score: 1, Won: false},
},
})
}
for i := 0; i < 2; i++ {
matchesCD = append(matchesCD, MatchData{
ID: fmt.Sprintf("cd_b_%d", i), WinnerID: "botD",
PlayedAt: now.Add(-time.Duration(10+i) * time.Hour),
Participants: []ParticipantData{
{BotID: "botC", Score: 1, Won: false},
{BotID: "botD", Score: 3, Won: true},
},
})
}
allMatches := append(matchesA, matchesCD...)
data := &IndexData{
GeneratedAt: now,
Bots: []BotData{
{ID: "botA", Name: "Alpha"},
{ID: "botB", Name: "Beta"},
{ID: "botC", Name: "Charlie"},
{ID: "botD", Name: "Delta"},
},
Matches: allMatches,
}
botNameMap := map[string]string{
"botA": "Alpha", "botB": "Beta", "botC": "Charlie", "botD": "Delta",
}
rivalries := computeRivalries(data, botNameMap)
if len(rivalries) != 2 {
t.Fatalf("Expected 2 rivalries, got %d", len(rivalries))
}
// The balanced pair (A/B) should rank higher than the imbalanced pair (C/D)
if rivalries[0].BotA.ID == "botC" || rivalries[0].BotB.ID == "botC" {
// Check if the balanced pair is second (would be wrong)
if rivalries[1].BotA.ID == "botA" || rivalries[1].BotB.ID == "botA" {
t.Error("Balanced pair (A/B) should rank higher than imbalanced pair (C/D)")
}
}
}
func TestComputeRivalries_TopKLimit(t *testing.T) {
now := time.Now()
// Create 25 qualifying pairs (each with 10 matches)
var matches []MatchData
for pair := 0; pair < 25; pair++ {
botA := fmt.Sprintf("bot_%02da", pair)
botB := fmt.Sprintf("bot_%02db", pair)
for i := 0; i < 10; i++ {
winner := botA
if i >= 5 {
winner = botB
}
matches = append(matches, MatchData{
ID: fmt.Sprintf("pair%d_m%d", pair, i),
WinnerID: winner,
PlayedAt: now.Add(-time.Duration(i) * time.Hour),
Participants: []ParticipantData{
{BotID: botA, Score: 3, Won: winner == botA},
{BotID: botB, Score: 2, Won: winner == botB},
},
})
}
}
data := &IndexData{
GeneratedAt: now,
Matches: matches,
}
rivalries := computeRivalries(data, nil)
if len(rivalries) > rivalryTopK {
t.Errorf("Expected at most %d rivalries, got %d", rivalryTopK, len(rivalries))
}
if len(rivalries) != rivalryTopK {
t.Errorf("Expected exactly %d rivalries (25 pairs qualify), got %d", rivalryTopK, len(rivalries))
}
}
func TestComputeRivalries_Draws(t *testing.T) {
now := time.Now()
// 10 matches with 4 draws
matches := []MatchData{
{ID: "m1", WinnerID: "bot1", PlayedAt: now.Add(-10 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 3, Won: true}, {BotID: "bot2", Score: 1, Won: false}}},
{ID: "m2", WinnerID: "bot2", PlayedAt: now.Add(-9 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 1, Won: false}, {BotID: "bot2", Score: 3, Won: true}}},
{ID: "m3", PlayedAt: now.Add(-8 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 2}, {BotID: "bot2", Score: 2}}}, // draw
{ID: "m4", WinnerID: "bot1", PlayedAt: now.Add(-7 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 4, Won: true}, {BotID: "bot2", Score: 2, Won: false}}},
{ID: "m5", PlayedAt: now.Add(-6 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 2}, {BotID: "bot2", Score: 2}}}, // draw
{ID: "m6", WinnerID: "bot2", PlayedAt: now.Add(-5 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 1, Won: false}, {BotID: "bot2", Score: 3, Won: true}}},
{ID: "m7", WinnerID: "bot1", PlayedAt: now.Add(-4 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 3, Won: true}, {BotID: "bot2", Score: 2, Won: false}}},
{ID: "m8", PlayedAt: now.Add(-3 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 3}, {BotID: "bot2", Score: 3}}}, // draw
{ID: "m9", WinnerID: "bot2", PlayedAt: now.Add(-2 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 2, Won: false}, {BotID: "bot2", Score: 4, Won: true}}},
{ID: "m10", PlayedAt: now.Add(-time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 1}, {BotID: "bot2", Score: 1}}}, // draw
}
data := &IndexData{
GeneratedAt: now,
Matches: matches,
}
rivalries := computeRivalries(data, map[string]string{"bot1": "A", "bot2": "B"})
if len(rivalries) != 1 {
t.Fatalf("Expected 1 rivalry, got %d", len(rivalries))
}
r := rivalries[0]
if r.Record.Draws != 4 {
t.Errorf("Draws: got %d, want 4", r.Record.Draws)
}
if r.Record.AWins+r.Record.BWins+r.Record.Draws != 10 {
t.Errorf("Total: got %d, want 10", r.Record.AWins+r.Record.BWins+r.Record.Draws)
}
}
func TestComputeRivalries_MultiPlayerSkipped(t *testing.T) {
now := time.Now()
// 10 two-player matches + 5 three-player matches (should be ignored)
var matches []MatchData
for i := 0; i < 10; i++ {
matches = append(matches, MatchData{
ID: fmt.Sprintf("2p_%d", i),
WinnerID: "bot1",
PlayedAt: now.Add(-time.Duration(i) * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true},
{BotID: "bot2", Score: 1, Won: false},
},
})
}
for i := 0; i < 5; i++ {
matches = append(matches, MatchData{
ID: fmt.Sprintf("3p_%d", i),
WinnerID: "bot1",
PlayedAt: now.Add(-time.Duration(i) * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true},
{BotID: "bot2", Score: 1, Won: false},
{BotID: "bot3", Score: 0, Won: false},
},
})
}
data := &IndexData{
GeneratedAt: now,
Matches: matches,
}
rivalries := computeRivalries(data, nil)
if len(rivalries) != 1 {
t.Fatalf("Expected 1 rivalry (only 2-player matches), got %d", len(rivalries))
}
// Should only count 10 two-player matches, not 15 total
if rivalries[0].TotalMatches != 10 {
t.Errorf("TotalMatches: got %d, want 10", rivalries[0].TotalMatches)
}
}
func TestComputeRivalries_RecencyBoost(t *testing.T) {
now := time.Now()
// Pair 1: all matches in the last week (high recency)
var recentMatches []MatchData
for i := 0; i < 10; i++ {
recentMatches = append(recentMatches, MatchData{
ID: fmt.Sprintf("recent_%d", i),
WinnerID: "bot1",
PlayedAt: now.Add(-time.Duration(i*12) * time.Hour), // within last 5 days
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true},
{BotID: "bot2", Score: 2, Won: false},
},
})
}
// Pair 2: all matches 6 months ago (low recency)
var oldMatches []MatchData
for i := 0; i < 10; i++ {
oldMatches = append(oldMatches, MatchData{
ID: fmt.Sprintf("old_%d", i),
WinnerID: "bot3",
PlayedAt: now.Add(-180*24*time.Hour - time.Duration(i)*time.Hour), // ~6 months ago
Participants: []ParticipantData{
{BotID: "bot3", Score: 3, Won: true},
{BotID: "bot4", Score: 2, Won: false},
},
})
}
allMatches := append(recentMatches, oldMatches...)
data := &IndexData{
GeneratedAt: now,
Matches: allMatches,
}
rivalries := computeRivalries(data, nil)
if len(rivalries) != 2 {
t.Fatalf("Expected 2 rivalries, got %d", len(rivalries))
}
// The recent pair should rank higher than the old pair
if rivalries[0].BotA.ID != "bot1" && rivalries[0].BotB.ID != "bot1" {
t.Error("Recent pair should rank higher than old pair due to recency weighting")
}
}
func TestLongestStreak(t *testing.T) {
tests := []struct {
name string
winners []string
botA string
botB string
wantLen int
wantNil bool
}{
{"empty", []string{}, "a", "b", 0, true},
{"single", []string{"a"}, "a", "b", 0, true}, // < 2
{"two streak", []string{"a", "a"}, "a", "b", 2, false},
{"alternating", []string{"a", "b", "a", "b"}, "a", "b", 0, true},
{"long streak", []string{"a", "a", "a", "b", "a", "a", "a", "a"}, "a", "b", 4, false},
{"draws break streak", []string{"a", "a", "draw", "a", "a"}, "a", "b", 2, false},
{"all draws", []string{"draw", "draw"}, "a", "b", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := longestStreak(tt.winners, tt.botA, tt.botB)
if tt.wantNil {
if result != nil {
t.Errorf("expected nil, got %+v", result)
}
} else {
if result == nil {
t.Fatal("expected non-nil streak")
}
if result.Length != tt.wantLen {
t.Errorf("streak length: got %d, want %d", result.Length, tt.wantLen)
}
}
})
}
}
func TestBuildRivalryNarrative(t *testing.T) {
tests := []struct {
name string
aWins int
bWins int
draws int
streak *RivalryStreak
wantEmpty bool
}{
{"tied", 5, 5, 0, nil, false},
{"tied with draws", 5, 5, 2, nil, false},
{"dominant", 8, 2, 0, nil, false},
{"with streak", 7, 3, 0, &RivalryStreak{Holder: "AlphaBot", Length: 4}, false},
{"close", 6, 4, 0, nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
narrative := buildRivalryNarrative("AlphaBot", "BetaBot", tt.aWins+tt.bWins+tt.draws, tt.aWins, tt.bWins, tt.draws, tt.streak)
if tt.wantEmpty && narrative != "" {
t.Errorf("expected empty narrative, got %q", narrative)
}
if !tt.wantEmpty && narrative == "" {
t.Error("expected non-empty narrative")
}
})
}
}
func TestGenerateRivalriesIndex(t *testing.T) {
tmpDir := t.TempDir()
rivalries := []RivalryEntry{
{
BotA: RivalryBot{ID: "bot1", Name: "Alpha"},
BotB: RivalryBot{ID: "bot2", Name: "Beta"},
TotalMatches: 15,
Record: RivalryRecord{AWins: 8, BWins: 7, Draws: 0},
Score: 2.34,
Narrative: "Alpha and Beta have met 15 times.",
},
}
if err := generateRivalriesIndex(rivalries, tmpDir); err != nil {
t.Fatalf("generateRivalriesIndex failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(tmpDir, "data", "meta", "rivalries.json"))
if err != nil {
t.Fatalf("Failed to read rivalries.json: %v", err)
}
var index RivalriesIndex
if err := json.Unmarshal(content, &index); err != nil {
t.Fatalf("Failed to parse rivalries.json: %v", err)
}
if len(index.Rivalries) != 1 {
t.Errorf("Expected 1 rivalry, got %d", len(index.Rivalries))
}
if index.UpdatedAt == "" {
t.Error("UpdatedAt should not be empty")
}
if index.Rivalries[0].BotA.Name != "Alpha" {
t.Errorf("BotA name: got %q, want %q", index.Rivalries[0].BotA.Name, "Alpha")
}
}
func TestGenerateRivalriesIndex_Empty(t *testing.T) {
tmpDir := t.TempDir()
if err := generateRivalriesIndex(nil, tmpDir); err != nil {
t.Fatalf("generateRivalriesIndex with nil failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(tmpDir, "data", "meta", "rivalries.json"))
if err != nil {
t.Fatalf("Failed to read rivalries.json: %v", err)
}
var index RivalriesIndex
if err := json.Unmarshal(content, &index); err != nil {
t.Fatalf("Failed to parse rivalries.json: %v", err)
}
if len(index.Rivalries) != 0 {
t.Errorf("Expected 0 rivalries, got %d", len(index.Rivalries))
}
}
func TestPairKey(t *testing.T) {
if pairKey("a", "b") != "a:b" {
t.Errorf("pairKey(a,b): got %q", pairKey("a", "b"))
}
if pairKey("b", "a") != "a:b" {
t.Errorf("pairKey(b,a): got %q", pairKey("b", "a"))
}
if pairKey("b", "a") != pairKey("a", "b") {
t.Error("pairKey should be canonical")
}
}

View file

@ -13,6 +13,7 @@ import (
"syscall"
"time"
"github.com/aicodebattle/acb/metrics"
_ "github.com/lib/pq"
"github.com/redis/go-redis/v9"
)
@ -94,6 +95,10 @@ func main() {
alerter := NewAlerter(cfg.DiscordWebhook, cfg.SlackWebhook)
// Start Prometheus metrics server
metricsSrv := metrics.StartServer()
defer metricsSrv.Close()
m := &Matchmaker{
cfg: cfg,
db: db,

View file

@ -9,6 +9,8 @@ import (
"math/rand"
"net/http"
"time"
"github.com/aicodebattle/acb/metrics"
)
const valkeyJobQueue = "acb:jobs:pending"
@ -180,6 +182,10 @@ func (m *Matchmaker) tickMatchmaker(ctx context.Context) {
return
}
// Update metrics
depth, _ := m.rdb.LLen(ctx, valkeyJobQueue).Result()
metrics.JobQueueDepth.Set(float64(depth))
log.Printf("matchmaker: created match %s (%s vs %s), job %s", matchID, botA.ID, botB.ID, jobID)
}
@ -242,6 +248,7 @@ func (m *Matchmaker) tickHealthChecker(ctx context.Context) {
if newStatus != bot.Status {
log.Printf("health-checker: %s marked inactive after %d failures", bot.ID, newFails)
m.alerter.BotMarkedInactive(ctx, bot.ID, newFails)
metrics.BotCrashed.Inc()
}
}
}
@ -297,6 +304,7 @@ func (m *Matchmaker) tickStaleReaper(ctx context.Context) {
log.Printf("stale-reaper: processed %d stale jobs", len(staleJobs))
m.alerter.StaleJobsReaped(ctx, staleJobs)
}
metrics.StaleJobCount.Set(float64(len(staleJobs)))
}
// queryActiveBotCount returns the number of active bots (used by tests).

9
go.mod
View file

@ -8,6 +8,7 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.19.12
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2
github.com/lib/pq v1.12.0
github.com/prometheus/client_golang v1.23.2
golang.org/x/image v0.38.0
)
@ -27,8 +28,16 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.35.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)

19
go.sum
View file

@ -36,15 +36,34 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

135
metrics/metrics.go Normal file
View file

@ -0,0 +1,135 @@
// Package metrics defines Prometheus metrics for AI Code Battle services per plan §9.9.
//
// All services import this package to expose a /metrics endpoint on an
// internal port (default :9090). The metrics match the 9 monitoring signals
// listed in the plan.
package metrics
import (
"net/http"
"os"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// §9.9 metric definitions — registered once at init time.
var (
// MatchThroughput counts completed matches (worker increments per result).
MatchThroughput = prometheus.NewCounter(prometheus.CounterOpts{
Name: "acb_match_throughput_total",
Help: "Total number of matches completed.",
})
// JobQueueDepth tracks the Valkey job queue length (matchmaker updates each tick).
JobQueueDepth = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "acb_job_queue_depth",
Help: "Current number of pending jobs in the Valkey queue.",
})
// BotCrashed counts bots marked as crashed by the health checker.
BotCrashed = prometheus.NewCounter(prometheus.CounterOpts{
Name: "acb_bot_crashed_total",
Help: "Total number of bot crash events detected by the health checker.",
})
// StaleJobCount is the number of stale jobs found in the last reaper cycle.
StaleJobCount = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "acb_job_stale_count",
Help: "Number of stale jobs found in the most recent reaper cycle.",
})
// R2BytesUsed tracks the R2 warm cache size in bytes (index-builder updates).
R2BytesUsed = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "acb_r2_bytes_used",
Help: "Total bytes used in the R2 warm cache.",
})
// ReplayUploadLatency tracks B2 replay upload duration.
ReplayUploadLatency = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "acb_replay_upload_latency_seconds",
Help: "Latency of replay uploads to B2 in seconds.",
Buckets: prometheus.DefBuckets,
})
// EvolverGenerations counts evolution cycles completed.
EvolverGenerations = prometheus.NewCounter(prometheus.CounterOpts{
Name: "acb_evolver_generations_total",
Help: "Total number of evolution generations completed.",
})
// IndexBuildDuration tracks how long each index build cycle takes.
IndexBuildDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "acb_index_build_duration_seconds",
Help: "Duration of index build cycles in seconds.",
Buckets: []float64{1, 5, 10, 30, 60, 120, 300, 600},
})
// HTTPRequestsTotal counts HTTP requests served by the API.
HTTPRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "acb_http_requests_total",
Help: "Total number of HTTP requests served.",
}, []string{"method", "path", "status"})
)
func init() {
prometheus.MustRegister(
MatchThroughput,
JobQueueDepth,
BotCrashed,
StaleJobCount,
R2BytesUsed,
ReplayUploadLatency,
EvolverGenerations,
IndexBuildDuration,
HTTPRequestsTotal,
)
}
// Handler returns an http.Handler that serves /metrics.
func Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.Handler())
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
return mux
}
// StartServer starts a Prometheus metrics HTTP server. Returns the server
// so the caller can shut it down gracefully. The address defaults to
// ACB_METRICS_ADDR env var, falling back to ":9090".
func StartServer() *http.Server {
addr := os.Getenv("ACB_METRICS_ADDR")
if addr == "" {
addr = ":9090"
}
srv := &http.Server{Addr: addr, Handler: Handler()}
go srv.ListenAndServe()
return srv
}
// HTTPMiddleware wraps an http.Handler to count requests via HTTPRequestsTotal.
func HTTPMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
HTTPRequestsTotal.WithLabelValues(r.Method, r.URL.Path, strconv.Itoa(sw.status)).Inc()
_ = start
})
}
// statusWriter wraps http.ResponseWriter to capture the status code.
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}

104
metrics/metrics_test.go Normal file
View file

@ -0,0 +1,104 @@
package metrics
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandlerReturnsOK(t *testing.T) {
h := Handler()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"status":"ok"`) {
t.Fatalf("unexpected body: %s", body)
}
}
func TestMetricsEndpoint(t *testing.T) {
// Increment some metrics to ensure they appear
MatchThroughput.Inc()
JobQueueDepth.Set(42)
HTTPRequestsTotal.WithLabelValues("GET", "/test", "200").Inc()
h := Handler()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
// Verify §9.9 metrics are present
expectedMetrics := []string{
"acb_match_throughput_total",
"acb_job_queue_depth",
"acb_bot_crashed_total",
"acb_job_stale_count",
"acb_r2_bytes_used",
"acb_replay_upload_latency_seconds",
"acb_evolver_generations_total",
"acb_index_build_duration_seconds",
"acb_http_requests_total",
}
for _, name := range expectedMetrics {
if !strings.Contains(body, name) {
t.Errorf("metrics output missing %q", name)
}
}
// Verify the incremented counter is present
if !strings.Contains(body, "acb_match_throughput_total ") {
t.Error("match throughput counter not found in output")
}
// Verify the gauge value
if !strings.Contains(body, "acb_job_queue_depth 42") {
t.Error("job queue depth gauge not found with expected value")
}
}
func TestHTTPRequestsCounter(t *testing.T) {
HTTPRequestsTotal.WithLabelValues("GET", "/api/status", "200").Inc()
HTTPRequestsTotal.WithLabelValues("POST", "/api/register", "201").Inc()
h := Handler()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
body := w.Body.String()
if !strings.Contains(body, `method="GET",path="/api/status",status="200"`) {
t.Error("labelled HTTP request counter not found")
}
if !strings.Contains(body, `method="POST",path="/api/register",status="201"`) {
t.Error("labelled HTTP request counter not found")
}
}
func TestHistogramObserved(t *testing.T) {
ReplayUploadLatency.Observe(1.5)
IndexBuildDuration.Observe(30.2)
h := Handler()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
body := w.Body.String()
if !strings.Contains(body, "acb_replay_upload_latency_seconds_bucket") {
t.Error("replay upload latency histogram not found")
}
if !strings.Contains(body, "acb_index_build_duration_seconds_bucket") {
t.Error("index build duration histogram not found")
}
}