R2: load-test harness + contour resource observability
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 3s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 3s
New scrabble/loadtest module (the pre-release stress harness): seeds 1000 guest + 10000 durable accounts with pre-created sessions directly in Postgres (token hash matches backend/internal/session), drives virtual players through the edge protocol (real 2-4p games assembled via invitations, mid-ranked legal moves generated locally by the embedded scrabble-solver — the edge carries no board, so the client replays history), plus nudge/chat/check-word/draft/profile/stats and a gateway-hammer that verifies the rate limiter. Prints a trip-report summary (per-op latency percentiles, result codes, live-event tally). Go unit tests cover the pure pieces; the DAWG-backed move test runs under BACKEND_DICT_DIR. Contour: add cAdvisor + postgres_exporter + a 'Scrabble - Resources' Grafana dashboard and the two Prometheus scrape jobs, for the R2/R7 stress-run resource baseline. CI: gate ./loadtest/... (path filter + vet/build/test). Docs: TESTING, ARCHITECTURE, project CLAUDE repo layout.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
// Package moves turns a game's public history and the caller's private rack into a
|
||||
// legal turn, by reconstructing the board and running the embedded scrabble-solver
|
||||
// locally (the edge protocol carries no board — the client replays history). It
|
||||
// picks a mid-ranked move so games progress realistically rather than optimally.
|
||||
package moves
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
|
||||
"scrabble/loadtest/internal/edge"
|
||||
)
|
||||
|
||||
// blankIndex is the rack/exchange sentinel for a blank tile on the wire (Stage 13).
|
||||
const blankIndex = 255
|
||||
|
||||
// variantSpec maps an edge variant label to its ruleset constructor and committed
|
||||
// DAWG filename (the descriptive names kept by R1).
|
||||
type variantSpec struct {
|
||||
ruleset func() *rules.Ruleset
|
||||
dawg string
|
||||
}
|
||||
|
||||
var specs = map[string]variantSpec{
|
||||
"scrabble_en": {rules.English, "en_sowpods.dawg"},
|
||||
"scrabble_ru": {rules.RussianScrabble, "ru_scrabble.dawg"},
|
||||
"erudit_ru": {rules.Erudit, "ru_erudit.dawg"},
|
||||
}
|
||||
|
||||
// Variants returns the edge variant labels the harness drives, in catalogue order.
|
||||
func Variants() []string { return []string{"scrabble_en", "scrabble_ru", "erudit_ru"} }
|
||||
|
||||
// engine is one loaded variant: its ruleset and a solver over its DAWG.
|
||||
type engine struct {
|
||||
rs *rules.Ruleset
|
||||
finder dawg.Finder
|
||||
solver *scrabble.Solver
|
||||
}
|
||||
|
||||
// Registry holds a solver per variant, built from the committed DAWGs in dir. It is
|
||||
// safe for concurrent use: every Pick builds its own board and rack, and the solver
|
||||
// holds only read-only state (the same way the backend shares one solver per variant
|
||||
// across concurrent games).
|
||||
type Registry struct {
|
||||
engines map[string]*engine
|
||||
}
|
||||
|
||||
// Open loads every variant's DAWG from dir and builds a solver over each. dir holds
|
||||
// the committed dawg files (the sibling scrabble-solver checkout's dawg/, or the
|
||||
// dictionary release artifact).
|
||||
func Open(dir string) (*Registry, error) {
|
||||
r := &Registry{engines: make(map[string]*engine)}
|
||||
for label, spec := range specs {
|
||||
rs := spec.ruleset()
|
||||
finder, err := dawg.Load(filepath.Join(dir, spec.dawg))
|
||||
if err != nil {
|
||||
r.Close()
|
||||
return nil, fmt.Errorf("moves: load %s dawg %s from %s: %w", label, spec.dawg, dir, err)
|
||||
}
|
||||
r.engines[label] = &engine{rs: rs, finder: finder, solver: scrabble.NewSolver(rs, finder)}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Close releases every loaded DAWG.
|
||||
func (r *Registry) Close() {
|
||||
for _, e := range r.engines {
|
||||
if e.finder != nil {
|
||||
_ = e.finder.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action is a chosen turn. Kind is "play", "exchange" or "pass". A play carries Dir
|
||||
// ("H"/"V") and Tiles; an exchange carries Exchange (rack indices to swap).
|
||||
type Action struct {
|
||||
Kind string
|
||||
Dir string
|
||||
Tiles []edge.PlayTile
|
||||
Exchange []byte
|
||||
}
|
||||
|
||||
// Pick reconstructs the board for variant from history, builds the rack from the
|
||||
// alphabet-index rack, generates the legal plays and returns a mid-ranked one. With
|
||||
// no legal play it exchanges (when the bag holds a full rack) or passes. rng makes
|
||||
// the choice deterministic per caller; pass each virtual player its own *rand.Rand
|
||||
// (rand.Rand is not safe for concurrent use).
|
||||
func (r *Registry) Pick(variant string, history []edge.Move, rackIdx []byte, bagLen int, rng *rand.Rand) (Action, error) {
|
||||
e, ok := r.engines[variant]
|
||||
if !ok {
|
||||
return Action{}, fmt.Errorf("moves: unknown variant %q", variant)
|
||||
}
|
||||
b, err := replayBoard(e.rs, history)
|
||||
if err != nil {
|
||||
return Action{}, err
|
||||
}
|
||||
legal := e.solver.GenerateMoves(b, buildRack(e.rs, rackIdx), scrabble.Both)
|
||||
if len(legal) == 0 {
|
||||
return noPlay(rackIdx, bagLen >= e.rs.RackSize), nil
|
||||
}
|
||||
m := midRanked(legal, rng)
|
||||
return Action{Kind: "play", Dir: dirString(m.Dir), Tiles: toPlayTiles(m.Tiles)}, nil
|
||||
}
|
||||
|
||||
// toPlayTiles maps the solver's newly-placed tiles to the edge submit-play tiles
|
||||
// (addressed by alphabet index, carrying the blank flag).
|
||||
func toPlayTiles(placements []scrabble.Placement) []edge.PlayTile {
|
||||
tiles := make([]edge.PlayTile, len(placements))
|
||||
for i, p := range placements {
|
||||
tiles[i] = edge.PlayTile{Row: p.Row, Col: p.Col, Letter: p.Letter, Blank: p.Blank}
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
|
||||
// replayBoard mirrors backend engine.ReplayBoard using only the solver's public API:
|
||||
// each play record's letters are re-indexed through the alphabet and applied to an
|
||||
// empty board. Non-play records are ignored.
|
||||
func replayBoard(rs *rules.Ruleset, history []edge.Move) (*board.Board, error) {
|
||||
b := board.New(rs.Rows, rs.Cols)
|
||||
for _, rec := range history {
|
||||
if rec.Action != "play" {
|
||||
continue
|
||||
}
|
||||
ps := make([]scrabble.Placement, len(rec.Tiles))
|
||||
for i, t := range rec.Tiles {
|
||||
idx, err := rs.Alphabet.Index(t.Letter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("moves: replay letter %q at (%d,%d): %w", t.Letter, t.Row, t.Col, err)
|
||||
}
|
||||
ps[i] = scrabble.Placement{Row: t.Row, Col: t.Col, Letter: idx, Blank: t.Blank}
|
||||
}
|
||||
scrabble.Apply(b, scrabble.Move{Tiles: ps})
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// buildRack turns the alphabet-index rack (255 a blank) into a solver Rack.
|
||||
func buildRack(rs *rules.Ruleset, rackIdx []byte) rack.Rack {
|
||||
rk := rack.New(rs.Alphabet.Size())
|
||||
for _, idx := range rackIdx {
|
||||
if idx == blankIndex {
|
||||
rk.AddBlank()
|
||||
} else {
|
||||
rk.Add(idx)
|
||||
}
|
||||
}
|
||||
return rk
|
||||
}
|
||||
|
||||
// midRanked returns a move from the middle third of the score-ranked list
|
||||
// (GenerateMoves returns highest-first), spreading the pick within that band with
|
||||
// rng. A tiny list yields its lowest-scoring move.
|
||||
func midRanked(moves []scrabble.Move, rng *rand.Rand) scrabble.Move {
|
||||
n := len(moves)
|
||||
if n <= 2 {
|
||||
return moves[n-1]
|
||||
}
|
||||
lo, hi := n/3, 2*n/3
|
||||
if hi <= lo {
|
||||
hi = lo + 1
|
||||
}
|
||||
return moves[lo+rng.Intn(hi-lo)]
|
||||
}
|
||||
|
||||
// noPlay chooses an exchange (when the bag can refill a full rack) or a pass.
|
||||
func noPlay(rackIdx []byte, canExchange bool) Action {
|
||||
if canExchange && len(rackIdx) > 0 {
|
||||
return Action{Kind: "exchange", Exchange: append([]byte(nil), rackIdx...)}
|
||||
}
|
||||
return Action{Kind: "pass"}
|
||||
}
|
||||
|
||||
// dirString renders a solver direction as the "H"/"V" the edge submit-play expects.
|
||||
func dirString(d scrabble.Direction) string {
|
||||
if d == scrabble.Vertical {
|
||||
return "V"
|
||||
}
|
||||
return "H"
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package moves
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
|
||||
"scrabble/loadtest/internal/edge"
|
||||
)
|
||||
|
||||
// TestReplayBoardMatchesParse checks that replaying decoded history reproduces the
|
||||
// exact board (positions, letters and blank flags) that board.Parse builds from the
|
||||
// equivalent text grid, and that non-play records are ignored.
|
||||
func TestReplayBoardMatchesParse(t *testing.T) {
|
||||
rs := rules.English()
|
||||
history := []edge.Move{
|
||||
{Action: "pass"}, // must be ignored
|
||||
{Action: "play", Tiles: []edge.Tile{
|
||||
{Row: 7, Col: 7, Letter: "c"},
|
||||
{Row: 7, Col: 8, Letter: "a"},
|
||||
{Row: 7, Col: 9, Letter: "t"},
|
||||
}},
|
||||
{Action: "play", Tiles: []edge.Tile{
|
||||
{Row: 7, Col: 10, Letter: "s", Blank: true}, // a blank standing for s
|
||||
}},
|
||||
}
|
||||
got, err := replayBoard(rs, history)
|
||||
if err != nil {
|
||||
t.Fatalf("replayBoard: %v", err)
|
||||
}
|
||||
|
||||
rows := make([]string, rs.Rows)
|
||||
for i := range rows {
|
||||
rows[i] = strings.Repeat(".", rs.Cols)
|
||||
}
|
||||
// row 7: cols 0-6 empty, cat at 7-9, an uppercase S (blank) at 10.
|
||||
rows[7] = strings.Repeat(".", 7) + "cat" + "S" + strings.Repeat(".", rs.Cols-11)
|
||||
want, err := board.Parse(rows, rs.Alphabet)
|
||||
if err != nil {
|
||||
t.Fatalf("board.Parse: %v", err)
|
||||
}
|
||||
for r := 0; r < rs.Rows; r++ {
|
||||
for c := 0; c < rs.Cols; c++ {
|
||||
if got.At(r, c) != want.At(r, c) {
|
||||
t.Fatalf("cell (%d,%d): replay = %#x, parse = %#x", r, c, got.At(r, c), want.At(r, c))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRack checks the alphabet-index rack (255 a blank) is reconstructed faithfully.
|
||||
func TestBuildRack(t *testing.T) {
|
||||
rs := rules.English()
|
||||
rk := buildRack(rs, []byte{0, 0, 2, blankIndex}) // a a c blank
|
||||
if rk.Count(0) != 2 {
|
||||
t.Errorf("count(a) = %d, want 2", rk.Count(0))
|
||||
}
|
||||
if rk.Count(2) != 1 {
|
||||
t.Errorf("count(c) = %d, want 1", rk.Count(2))
|
||||
}
|
||||
if rk.Blanks() != 1 {
|
||||
t.Errorf("blanks = %d, want 1", rk.Blanks())
|
||||
}
|
||||
if rk.Total() != 4 {
|
||||
t.Errorf("total = %d, want 4", rk.Total())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMidRanked checks the pick always lands in the middle third of a ranked list and
|
||||
// that tiny lists yield their lowest-scoring move.
|
||||
func TestMidRanked(t *testing.T) {
|
||||
ms := make([]scrabble.Move, 9) // scores 100..92, index i has score 100-i
|
||||
for i := range ms {
|
||||
ms[i] = scrabble.Move{Score: 100 - i}
|
||||
}
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
for n := 0; n < 100; n++ {
|
||||
idx := 100 - midRanked(ms, rng).Score // recover the index from the score
|
||||
if idx < 3 || idx >= 6 {
|
||||
t.Fatalf("picked index %d outside middle third [3,6)", idx)
|
||||
}
|
||||
}
|
||||
if got := midRanked([]scrabble.Move{{Score: 5}}, rng).Score; got != 5 {
|
||||
t.Errorf("n=1 pick score = %d, want 5", got)
|
||||
}
|
||||
if got := midRanked([]scrabble.Move{{Score: 9}, {Score: 4}}, rng).Score; got != 4 {
|
||||
t.Errorf("n=2 pick score = %d, want 4 (lower-scoring)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToPlayTiles checks the solver-placement to edge-tile mapping, including blanks.
|
||||
func TestToPlayTiles(t *testing.T) {
|
||||
tiles := toPlayTiles([]scrabble.Placement{
|
||||
{Row: 1, Col: 2, Letter: 5},
|
||||
{Row: 1, Col: 3, Letter: 255, Blank: true},
|
||||
})
|
||||
want := []edge.PlayTile{
|
||||
{Row: 1, Col: 2, Letter: 5},
|
||||
{Row: 1, Col: 3, Letter: 255, Blank: true},
|
||||
}
|
||||
if len(tiles) != len(want) {
|
||||
t.Fatalf("len = %d, want %d", len(tiles), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if tiles[i] != want[i] {
|
||||
t.Errorf("tile %d = %+v, want %+v", i, tiles[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickUnknownVariant rejects a variant the registry does not hold.
|
||||
func TestPickUnknownVariant(t *testing.T) {
|
||||
reg := &Registry{engines: map[string]*engine{}}
|
||||
if _, err := reg.Pick("nope", nil, nil, 0, rand.New(rand.NewSource(1))); err == nil {
|
||||
t.Fatal("want error for an unknown variant")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickWithDawg drives the full path against the committed DAWGs when they are
|
||||
// available (BACKEND_DICT_DIR, as the engine tests use); it generates a first-move
|
||||
// play from a productive rack.
|
||||
func TestPickWithDawg(t *testing.T) {
|
||||
dir := os.Getenv("BACKEND_DICT_DIR")
|
||||
if dir == "" {
|
||||
t.Skip("BACKEND_DICT_DIR not set; skipping DAWG-backed test")
|
||||
}
|
||||
reg, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Open(%s): %v", dir, err)
|
||||
}
|
||||
defer reg.Close()
|
||||
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
rack := []byte{2, 0, 19, 18, 4, 17, 13} // c a t s e r n — a productive English rack
|
||||
act, err := reg.Pick("scrabble_en", nil, rack, 90, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("Pick: %v", err)
|
||||
}
|
||||
switch act.Kind {
|
||||
case "play":
|
||||
if len(act.Tiles) == 0 {
|
||||
t.Error("play action has no tiles")
|
||||
}
|
||||
if act.Dir != "H" && act.Dir != "V" {
|
||||
t.Errorf("dir = %q, want H or V", act.Dir)
|
||||
}
|
||||
case "exchange", "pass":
|
||||
// acceptable when the rack has no legal first move
|
||||
default:
|
||||
t.Errorf("unexpected action kind %q", act.Kind)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user