diff --git a/cmd/acb-evolver/internal/arena/arena_test.go b/cmd/acb-evolver/internal/arena/arena_test.go index 5196cdb..f1efb40 100644 --- a/cmd/acb-evolver/internal/arena/arena_test.go +++ b/cmd/acb-evolver/internal/arena/arena_test.go @@ -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) diff --git a/cmd/acb-evolver/internal/arena/gate.go b/cmd/acb-evolver/internal/arena/gate.go index beb1182..0af4ede 100644 --- a/cmd/acb-evolver/internal/arena/gate.go +++ b/cmd/acb-evolver/internal/arena/gate.go @@ -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 diff --git a/cmd/acb-evolver/internal/crosspoll/crosspoll.go b/cmd/acb-evolver/internal/crosspoll/crosspoll.go index fcc3260..c8dd1b8 100644 --- a/cmd/acb-evolver/internal/crosspoll/crosspoll.go +++ b/cmd/acb-evolver/internal/crosspoll/crosspoll.go @@ -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 +} diff --git a/cmd/acb-evolver/internal/mapelites/grid.go b/cmd/acb-evolver/internal/mapelites/grid.go index 50ede15..a70e0f0 100644 --- a/cmd/acb-evolver/internal/mapelites/grid.go +++ b/cmd/acb-evolver/internal/mapelites/grid.go @@ -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 +} diff --git a/cmd/acb-evolver/internal/mapelites/grid_test.go b/cmd/acb-evolver/internal/mapelites/grid_test.go index 4bbcbaf..d0450a2 100644 --- a/cmd/acb-evolver/internal/mapelites/grid_test.go +++ b/cmd/acb-evolver/internal/mapelites/grid_test.go @@ -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) + } +} diff --git a/cmd/acb-evolver/main.go b/cmd/acb-evolver/main.go index f98df01..db4f04e 100644 --- a/cmd/acb-evolver/main.go +++ b/cmd/acb-evolver/main.go @@ -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) } } } diff --git a/cmd/acb-evolver/run.go b/cmd/acb-evolver/run.go index dfbe914..ceb8baa 100644 --- a/cmd/acb-evolver/run.go +++ b/cmd/acb-evolver/run.go @@ -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 { diff --git a/cmd/acb-index-builder/generator.go b/cmd/acb-index-builder/generator.go index cabce0f..5f95143 100644 --- a/cmd/acb-index-builder/generator.go +++ b/cmd/acb-index-builder/generator.go @@ -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 { diff --git a/cmd/acb-index-builder/rivalry_test.go b/cmd/acb-index-builder/rivalry_test.go new file mode 100644 index 0000000..d49aad6 --- /dev/null +++ b/cmd/acb-index-builder/rivalry_test.go @@ -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") + } +} diff --git a/cmd/acb-matchmaker/main.go b/cmd/acb-matchmaker/main.go index 2833c06..5576baf 100644 --- a/cmd/acb-matchmaker/main.go +++ b/cmd/acb-matchmaker/main.go @@ -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, diff --git a/cmd/acb-matchmaker/tickers.go b/cmd/acb-matchmaker/tickers.go index 951e923..0981216 100644 --- a/cmd/acb-matchmaker/tickers.go +++ b/cmd/acb-matchmaker/tickers.go @@ -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). diff --git a/go.mod b/go.mod index 1752e22..11b479e 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 35fc4a8..8a45cd2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/metrics/metrics.go b/metrics/metrics.go new file mode 100644 index 0000000..3e72d1a --- /dev/null +++ b/metrics/metrics.go @@ -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) +} diff --git a/metrics/metrics_test.go b/metrics/metrics_test.go new file mode 100644 index 0000000..b76aff8 --- /dev/null +++ b/metrics/metrics_test.go @@ -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") + } +}