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:
parent
4a92539c6f
commit
80334c6e34
15 changed files with 1433 additions and 152 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
495
cmd/acb-index-builder/rivalry_test.go
Normal file
495
cmd/acb-index-builder/rivalry_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
9
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
|
||||
)
|
||||
|
|
|
|||
19
go.sum
19
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=
|
||||
|
|
|
|||
135
metrics/metrics.go
Normal file
135
metrics/metrics.go
Normal 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
104
metrics/metrics_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue