diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 030a421..4b10fc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - name: gofmt run: test -z "$(gofmt -l cmd internal)" || (gofmt -l cmd internal; exit 1) - name: vet - run: go vet ./... + run: go vet ./... && CGO_ENABLED=1 go vet -tags onnx ./... - name: install GNU Go (arena integration test) run: sudo apt-get install -y gnugo - name: test @@ -24,6 +24,12 @@ jobs: run: go test -race -short -count=1 ./... - name: pure-Go build run: CGO_ENABLED=0 go build -o bin/gofer ./cmd/gofer + - name: WebAssembly engine (web/engine/gofer.wasm) + run: GOOS=js GOARCH=wasm go vet ./cmd/gofer-wasm && GOOS=js GOARCH=wasm go build -o /tmp/gofer.wasm ./cmd/gofer-wasm + - name: site scripts and data + run: | + node --check web/site.js && node --check web/engine.js + for f in web/data/*.json; do node -e "JSON.parse(require('fs').readFileSync('$f', 'utf8'))" || exit 1; done python: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index d547367..1b718d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,20 @@ A rewrite of the engine core and the training pipeline. See - Evaluation: arena with paired openings, GNU Go (or any GTP engine) as anchors with dead-stone adjudication, Wilson intervals, Elo, SPRT, and a Bradley-Terry ladder across generations +### Web + +- Redesigned landing page: hand-written CSS and self-hosted fonts (no CSS build step, no runtime CDN + for styles), canvas boards, inline SVG charts, new icons and social image +- Play the real engine in the browser: `cmd/gofer-wasm` compiles rules and search to WebAssembly and + evaluates the network with onnxruntime-web, loaded on demand. The search yields to the browser + every 16 visits, so the page stays responsive while Gofer thinks; a new game or undo cancels a + running search +- The hero replays a real win over GNU Go level 10 with Gofer's own analysis of every position +- "Watch it learn": all 18 generations with one self-play game each, the raw opening policy, and the + Elo ladder against GNU Go, generated from the training run by `cmd/sitedata` (`make site-data`) +- Players pass once the opponent has passed and passing wins on the count, instead of filling their + own territory + ### Removed - HTTP inference sidecar, heuristic evaluator, JSONL samples, SGF-to-JSONL converter, Lightsail/AWS diff --git a/Makefile b/Makefile index 015c86c..a11cdb2 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ PYTHON ?= .venv/bin/python MODEL ?= models/gofer-9x9.onnx RUN ?= runs/9x9 -.PHONY: build build-nocgo test test-go test-py race lint bench gtp play train ort +.PHONY: build build-nocgo test test-go test-py race lint bench gtp play train ort site site-data # Engine with ONNX Runtime (needs CGO and scripts/install-ort.sh). build: @@ -43,3 +43,11 @@ train: build ort: scripts/install-ort.sh + +# Static site in web/: the engine as WebAssembly plus the released network (outputs are committed). +site: + scripts/build-site.sh + +# The page's data (hero analysis, per-generation openings, games, Elo) from a training run. +site-data: + CGO_ENABLED=1 go run -tags onnx ./cmd/sitedata -run $(RUN) diff --git a/README.md b/README.md index 63b23b0..8445718 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,17 @@ In Sabaki: *Engines → Manage engines → Add*, path `bin/gofer`, arguments `gtp -model models/gofer-9x9.onnx`. Live analysis (`lz-analyze`) works out of the box. +## Play it in the browser + +The landing page in [`web/`](web/) runs the same engine compiled to +WebAssembly, with the network evaluated by onnxruntime-web: play Gofer, +watch its analysis of a win over GNU Go, and scrub through all 18 +generations of the training run. The page is plain HTML, CSS and JavaScript; +`make site` rebuilds the engine (`GOOS=js GOARCH=wasm`) and copies the model, +and `make site-data RUN=runs/9x9` regenerates `web/data/` from a training run +(`cmd/sitedata`). Outputs are committed, so any static host can serve `web/`; +preview with `python3 -m http.server -d web 8000`. + ## Train your own ```bash @@ -79,6 +90,8 @@ bin/gofer arena -a nn:models/gofer-9x9.onnx,visits=400 -b gnugo,level=10 -games | `internal/nn` | features, batched evaluator, ONNX Runtime backend | | `internal/selfplay`, `internal/arena`, `internal/gtp`, `internal/sgf` | applications | | `cmd/gofer` | CLI | +| `cmd/gofer-wasm`, `web/` | the engine in WebAssembly and the landing page | +| `cmd/sitedata` | builds the landing page's data from a training run | | `training/` | model, data, trainer, export, loop, Elo, report (+ tests) | | `docs/` | [architecture](docs/architecture.md), [training](docs/training.md), [v2 audit](docs/v2-audit.md) | diff --git a/cmd/gofer-wasm/bits.go b/cmd/gofer-wasm/bits.go new file mode 100644 index 0000000..e818b1e --- /dev/null +++ b/cmd/gofer-wasm/bits.go @@ -0,0 +1,8 @@ +//go:build js && wasm + +package main + +import "math" + +func mathFloat32bits(f float32) uint32 { return math.Float32bits(f) } +func mathFloat32frombits(u uint32) float32 { return math.Float32frombits(u) } diff --git a/cmd/gofer-wasm/main.go b/cmd/gofer-wasm/main.go new file mode 100644 index 0000000..5c8b862 --- /dev/null +++ b/cmd/gofer-wasm/main.go @@ -0,0 +1,323 @@ +//go:build js && wasm + +// Command gofer-wasm runs the Gofer engine (rules and search) in the browser. +// The network is evaluated by JavaScript (onnxruntime-web) through an +// asynchronous callback, so the same search plays on the web page as in the +// native binary. It installs a global `gofer` object; see web/engine.js. +package main + +import ( + "errors" + "math/rand" + "syscall/js" + + "github.com/DevomB/gofer/internal/game" + "github.com/DevomB/gofer/internal/mcts" + "github.com/DevomB/gofer/internal/nn" +) + +const size = 9 + +type engine struct { + komi float64 + board *game.Board + moves []game.Point + search *mcts.Search + eval *jsEval + epoch int // bumped by every reset, so a search still running for the old game gives up +} + +var errCancelled = errors.New("gofer: search cancelled by a new game or undo") + +func (e *engine) reset() { + e.epoch++ + e.board = game.NewBoard(game.Options{Size: size, Komi: e.komi, Superko: true}) + e.moves = nil + cfg := mcts.DefaultConfig() + cfg.Seed = rand.Int63() + e.search = mcts.New(cfg, e.eval) + e.search.Reset(e.board) +} + +func (e *engine) play(m game.Point) error { + if err := e.board.Play(m); err != nil { + return err + } + e.moves = append(e.moves, m) + e.search.Advance(m, e.board) + return nil +} + +// jsEval evaluates positions by calling a JavaScript function +// (spatial Float32Array, global Float32Array, n) -> Promise<{policy, value}>. +type jsEval struct { + fn js.Value + rng *rand.Rand + cache map[uint64]nn.Output +} + +func (j *jsEval) Eval(boards []*game.Board, out []nn.Output) error { + if j.fn.Type() != js.TypeFunction { + return errors.New("gofer: no evaluator set") + } + points := size * size + var todo []int + for i, b := range boards { + if o, ok := j.cache[b.Key()]; ok { + out[i] = o + } else { + todo = append(todo, i) + } + } + if len(todo) == 0 { + return nil + } + m := len(todo) + spatial := make([]float32, m*nn.NumPlanes*points) + global := make([]float32, m*nn.NumGlobals) + syms := make([]int, m) + for k, i := range todo { + syms[k] = j.rng.Intn(8) + nn.Encode(boards[i], syms[k], spatial[k*nn.NumPlanes*points:(k+1)*nn.NumPlanes*points], global[k*nn.NumGlobals:(k+1)*nn.NumGlobals]) + } + res, err := await(j.fn.Invoke(float32Array(spatial), float32Array(global), m)) + if err != nil { + return err + } + logits := goFloats(res.Get("policy"), m*(points+1)) + values := goFloats(res.Get("value"), m) + for k, i := range todo { + row := logits[k*(points+1) : (k+1)*(points+1)] + nn.Softmax(row) + pol := make([]float32, points+1) + t := nn.Symmetry(size, syms[k]) + for p := 0; p < points; p++ { + pol[p] = row[t[p]] + } + pol[points] = row[points] + out[i] = nn.Output{Policy: pol, Value: values[k]} + if len(j.cache) > 200_000 { + clear(j.cache) + } + j.cache[boards[i].Key()] = out[i] + } + return nil +} + +// await blocks the calling goroutine until a JS promise settles. It must not +// run on the event-loop goroutine (js.FuncOf callbacks spawn goroutines first). +func await(p js.Value) (js.Value, error) { + type result struct { + v js.Value + err error + } + ch := make(chan result, 1) + var onOK, onErr js.Func + onOK = js.FuncOf(func(_ js.Value, args []js.Value) any { + ch <- result{v: args[0]} + return nil + }) + onErr = js.FuncOf(func(_ js.Value, args []js.Value) any { + ch <- result{err: errors.New(args[0].Call("toString").String())} + return nil + }) + p.Call("then", onOK, onErr) + r := <-ch + onOK.Release() + onErr.Release() + return r.v, r.err +} + +// yieldToBrowser lets the page render and handle input. The search runs on +// the page's only thread, and network evaluations resolve as microtasks, so +// without this a long search freezes the page until it finishes. +func yieldToBrowser() { + var cb js.Func + cb = js.FuncOf(func(_ js.Value, args []js.Value) any { + js.Global().Call("setTimeout", args[0], 0) + return nil + }) + await(js.Global().Get("Promise").New(cb)) + cb.Release() +} + +func float32Array(xs []float32) js.Value { + arr := js.Global().Get("Float32Array").New(len(xs)) + buf := js.Global().Get("Uint8Array").New(arr.Get("buffer")) + b := make([]byte, 4*len(xs)) + for i, x := range xs { + u := mathFloat32bits(x) + b[4*i], b[4*i+1], b[4*i+2], b[4*i+3] = byte(u), byte(u>>8), byte(u>>16), byte(u>>24) + } + js.CopyBytesToJS(buf, b) + return arr +} + +func goFloats(v js.Value, n int) []float32 { + b := make([]byte, 4*n) + js.CopyBytesToGo(b, js.Global().Get("Uint8Array").New(v.Get("buffer"), v.Get("byteOffset"), 4*n)) + out := make([]float32, n) + for i := range out { + out[i] = mathFloat32frombits(uint32(b[4*i]) | uint32(b[4*i+1])<<8 | uint32(b[4*i+2])<<16 | uint32(b[4*i+3])<<24) + } + return out +} + +func (e *engine) state() map[string]any { + b := e.board + cells := make([]any, b.NumPoints()) + for p := range cells { + cells[p] = int(b.At(game.Point(p))) + } + var legal []any + for p := game.Point(0); int(p) < b.NumPoints(); p++ { + if b.IsLegal(p) { + legal = append(legal, int(p)) + } + } + last := -2 + if m, ok := b.LastMove(0); ok { + last = int(m) + } + return map[string]any{ + "size": size, + "cells": cells, + "toPlay": int(b.ToPlay()), + "ko": int(b.Ko()), + "last": last, + "moveNum": b.MoveNum(), + "gameOver": b.GameOver(), + "margin": b.Margin(), + "capturesB": b.Captures(game.Black), + "capturesW": b.Captures(game.White), + "legal": legal, + } +} + +// promise runs fn on a new goroutine and returns a JS Promise for its result. +func promise(fn func() (any, error)) js.Value { + handler := js.FuncOf(func(_ js.Value, args []js.Value) any { + resolve, reject := args[0], args[1] + go func() { + v, err := fn() + if err != nil { + reject.Invoke(js.Global().Get("Error").New(err.Error())) + return + } + resolve.Invoke(v) + }() + return nil + }) + return js.Global().Get("Promise").New(handler) +} + +func main() { + e := &engine{komi: 7.5, eval: &jsEval{rng: rand.New(rand.NewSource(rand.Int63())), cache: map[uint64]nn.Output{}}} + e.reset() + busy, busyEpoch := false, 0 + api := map[string]any{ + "setEvaluator": js.FuncOf(func(_ js.Value, args []js.Value) any { + e.eval.fn = args[0] + return nil + }), + "newGame": js.FuncOf(func(_ js.Value, args []js.Value) any { + if len(args) > 0 && args[0].Type() == js.TypeNumber { + e.komi = args[0].Float() + } + e.reset() + return js.ValueOf(e.state()) + }), + "state": js.FuncOf(func(js.Value, []js.Value) any { return js.ValueOf(e.state()) }), + "play": js.FuncOf(func(_ js.Value, args []js.Value) any { + if busy && busyEpoch == e.epoch { + return js.ValueOf(map[string]any{"ok": false, "error": "engine is thinking"}) + } + if err := e.play(game.Point(args[0].Int())); err != nil { + return js.ValueOf(map[string]any{"ok": false, "error": err.Error()}) + } + return js.ValueOf(map[string]any{"ok": true, "state": e.state()}) + }), + "undo": js.FuncOf(func(_ js.Value, args []js.Value) any { + n := 1 + if len(args) > 0 { + n = args[0].Int() + } + moves := e.moves + if n > len(moves) { + n = len(moves) + } + e.reset() + for _, m := range moves[:len(moves)-n] { + e.play(m) + } + return js.ValueOf(e.state()) + }), + // genmove(visits, onProgress?) -> Promise<{move, value, visits, pv, top, state}> + "genmove": js.FuncOf(func(_ js.Value, args []js.Value) any { + visits := args[0].Int() + var progress js.Value + if len(args) > 1 { + progress = args[1] + } + return promise(func() (any, error) { + for busy { // a cancelled search stops at its next step + yieldToBrowser() + } + busy, busyEpoch = true, e.epoch + defer func() { busy = false }() + epoch := e.epoch + if e.search.PassWins() { + res := summary(e.search, 0, 0) + if err := e.play(game.Pass); err != nil { + return nil, err + } + res["move"] = int(game.Pass) + res["state"] = e.state() + return js.ValueOf(res), nil + } + for done := 0; done < visits; { + step := min(16, visits-done) + if err := e.search.Run(step); err != nil { + return nil, err + } + done += step + if progress.Type() == js.TypeFunction { + progress.Invoke(js.ValueOf(summary(e.search, done, visits))) + } + yieldToBrowser() + if e.epoch != epoch { + return nil, errCancelled + } + } + res := summary(e.search, visits, visits) + m := e.search.BestMove() + if err := e.play(m); err != nil { + return nil, err + } + res["move"] = int(m) + res["state"] = e.state() + return js.ValueOf(res), nil + }) + }), + } + js.Global().Set("gofer", js.ValueOf(api)) + if cb := js.Global().Get("goferReady"); cb.Type() == js.TypeFunction { + cb.Invoke() + } + select {} +} + +func summary(s *mcts.Search, done, total int) map[string]any { + var top []any + for i, c := range s.Children() { + if i == 5 || c.Visits == 0 { + break + } + top = append(top, map[string]any{"move": int(c.Move), "visits": c.Visits, "q": c.Q, "prior": float64(c.Prior)}) + } + var pv []any + for _, m := range s.PV(8) { + pv = append(pv, int(m)) + } + return map[string]any{"value": s.RootValue(), "visits": s.Visits(), "done": done, "total": total, "top": top, "pv": pv} +} diff --git a/cmd/gofer/commands.go b/cmd/gofer/commands.go index 2968d3f..d5b452e 100644 --- a/cmd/gofer/commands.go +++ b/cmd/gofer/commands.go @@ -89,6 +89,12 @@ func cmdPlay(args []string) error { } m = p } else { + if s.PassWins() { + fmt.Println("Gofer passes: the game is decided.") + b.Play(game.Pass) + s.Advance(game.Pass, b) + continue + } if err := s.Run(sf.visits); err != nil { return err } diff --git a/cmd/sitedata/main.go b/cmd/sitedata/main.go new file mode 100644 index 0000000..10d32c4 --- /dev/null +++ b/cmd/sitedata/main.go @@ -0,0 +1,386 @@ +//go:build onnx + +// Command sitedata builds the JSON the web page draws from a training run: +// +// web/data/hero.json Gofer's own analysis of every position of a game +// web/data/generations.json per generation: Elo, opening policy, one self-play game +// +// go run -tags onnx ./cmd/sitedata -run runs/main -hero docs/results/games/gnugo-l10-vs-gofer.sgf +// +// Every number on the page traces back to this command and the run directory. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "math" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/DevomB/gofer/internal/game" + "github.com/DevomB/gofer/internal/mcts" + "github.com/DevomB/gofer/internal/nn" + "github.com/DevomB/gofer/internal/sgf" +) + +const size = 9 + +func main() { + run := flag.String("run", "runs/main", "training run directory (models/, sgf/, eval/ratings)") + ratings := flag.String("ratings", "docs/results/ratings.json", "Bradley-Terry ratings written by training/report.py") + model := flag.String("model", "models/gofer-9x9.onnx", "network that analyses the hero game") + hero := flag.String("hero", "docs/results/games/gnugo-l10-vs-gofer.sgf", "game to analyse for the hero") + extra := flag.String("extra-sgf", "", "self-play SGFs for generations the run has none for (dir with gen-NNNN/ subdirs)") + visits := flag.Int("visits", 800, "search visits per analysed position") + out := flag.String("out", "web/data", "output directory") + flag.Parse() + log.SetFlags(0) + + if err := os.MkdirAll(*out, 0o755); err != nil { + log.Fatal(err) + } + h, err := analyseGame(*hero, *model, *visits) + if err != nil { + log.Fatal(err) + } + write(filepath.Join(*out, "hero.json"), h) + + g, err := generations(*run, *ratings, *extra) + if err != nil { + log.Fatal(err) + } + write(filepath.Join(*out, "generations.json"), g) +} + +func write(path string, v any) { + b, err := json.Marshal(v) + if err != nil { + log.Fatal(err) + } + if err := os.WriteFile(path, b, 0o644); err != nil { + log.Fatal(err) + } + log.Printf("wrote %s (%d bytes)", path, len(b)) +} + +// pt is a point index (y*9+x, y=0 the top row), -1 for pass. +func pt(p game.Point) int { + if p == game.Pass { + return -1 + } + return int(p) +} + +func round(x float64, digits int) float64 { + s := math.Pow(10, float64(digits)) + return math.Round(x*s) / s +} + +// ---- hero ---- + +type candidate struct { + P int `json:"p"` + N int `json:"n"` + W float64 `json:"w"` // win probability for the side to move + Pr float64 `json:"pr"` +} + +type frame struct { + Color string `json:"c"` + P int `json:"p"` + BW float64 `json:"bw"` // Black's win probability before the move + N int `json:"n"` + Top []candidate `json:"top"` + PV []int `json:"pv"` +} + +type heroGame struct { + Black string `json:"black"` + White string `json:"white"` + Result string `json:"result"` + Komi float64 `json:"komi"` + Visits int `json:"visits"` + Moves []frame `json:"moves"` +} + +func readGame(path string) (*sgf.Game, []sgf.Move, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + g, err := sgf.Parse(string(data)) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", path, err) + } + moves, err := g.MainLine() + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", path, err) + } + return g, moves, nil +} + +func analyseGame(path, model string, visits int) (*heroGame, error) { + g, moves, err := readGame(path) + if err != nil { + return nil, err + } + net, err := nn.NewNet(nn.NetConfig{Size: size, Workers: 4, MaxBatch: 32, RandomSymmetry: true, CacheEntries: 200_000, Seed: 1}, + func() (nn.Backend, error) { return nn.NewORTBackend(model, size, nn.ORTOptions{}) }) + if err != nil { + return nil, err + } + defer net.Close() + + b := game.NewBoard(game.Options{Size: size, Komi: g.Komi, Superko: true}) + cfg := mcts.DefaultConfig() + cfg.Seed = 1 + s := mcts.New(cfg, net) + s.Reset(b) + h := &heroGame{Black: g.Root.Get("PB"), White: g.Root.Get("PW"), Result: g.Result, Komi: g.Komi, Visits: visits} + for i, m := range moves { + if err := s.Run(visits); err != nil { + return nil, err + } + v := s.RootValue() + bw := (1 + v) / 2 + if b.ToPlay() == game.White { + bw = 1 - bw + } + f := frame{Color: colorName(m.Color), P: pt(m.Point), BW: round(bw, 3), N: s.Visits()} + for j, c := range s.Children() { + if j == 5 || c.Visits == 0 { + break + } + f.Top = append(f.Top, candidate{P: pt(c.Move), N: c.Visits, W: round((1+c.Q)/2, 3), Pr: round(float64(c.Prior), 3)}) + } + for _, p := range s.PV(6) { + f.PV = append(f.PV, pt(p)) + } + h.Moves = append(h.Moves, f) + if err := b.Play(m.Point); err != nil { + return nil, fmt.Errorf("move %d: %w", i+1, err) + } + s.Advance(m.Point, b) + } + return h, nil +} + +func colorName(c game.Color) string { + if c == game.White { + return "W" + } + return "B" +} + +// ---- generations ---- + +type generation struct { + Gen int `json:"gen"` + Elo float64 `json:"elo"` + Stderr float64 `json:"se"` + Opening []float64 `json:"opening"` // raw policy on the empty board, 81 points + pass + Value float64 `json:"value"` // Black's win probability on the empty board + Game *spGame `json:"game,omitempty"` + // From the run's metrics.jsonl: evaluation matches and the policy loss of + // this network on the self-play data it generated (before training on it). + Results []result `json:"results,omitempty"` + PolicyLoss float64 `json:"policyLoss,omitempty"` +} + +type result struct { + Opp string `json:"opp"` + Wins int `json:"w"` + Losses int `json:"l"` +} + +type ladder struct { + Gens []generation `json:"gens"` + Anchors map[string]float64 `json:"anchors"` // Elo of the fixed opponents on the same scale +} + +type spGame struct { + Result string `json:"result"` + Moves []int `json:"moves"` +} + +func generations(run, ratingsPath, extra string) (*ladder, error) { + data, err := os.ReadFile(ratingsPath) + if err != nil { + return nil, err + } + var ratings map[string]struct { + Elo float64 `json:"elo"` + Stderr float64 `json:"stderr"` + } + if err := json.Unmarshal(data, &ratings); err != nil { + return nil, err + } + metrics, err := readMetrics(filepath.Join(run, "metrics.jsonl")) + if err != nil { + return nil, err + } + models, err := filepath.Glob(filepath.Join(run, "models", "gen-*.onnx")) + if err != nil { + return nil, err + } + sort.Strings(models) + l := &ladder{Anchors: map[string]float64{}} + for name, r := range ratings { + if !strings.HasPrefix(name, "gen-") { + l.Anchors[name] = round(r.Elo, 1) + } + } + for i, path := range models { + name := strings.TrimSuffix(filepath.Base(path), ".onnx") + r, ok := ratings[name] + if !ok { + return nil, fmt.Errorf("%s: no rating", name) + } + pol, val, err := openingPolicy(path) + if err != nil { + return nil, err + } + gen := generation{Gen: i, Elo: round(r.Elo, 1), Stderr: round(r.Stderr, 1), Opening: pol, Value: round(val, 3)} + if m, ok := metrics[i]; ok { + gen.Results, gen.PolicyLoss = m.results, round(m.policyLoss, 3) + } + dirs := []string{filepath.Join(run, "sgf", name)} + if extra != "" { + dirs = append(dirs, filepath.Join(extra, name)) + } + for _, dir := range dirs { + if gen.Game, err = typicalGame(dir); err != nil { + return nil, err + } + if gen.Game != nil { + break + } + } + log.Printf("%s: elo %.0f, opening max %.3f, game %v", name, r.Elo, maxOf(pol[:size*size]), gen.Game != nil) + l.Gens = append(l.Gens, gen) + } + return l, nil +} + +type genMetrics struct { + results []result + policyLoss float64 +} + +func readMetrics(path string) (map[int]genMetrics, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + out := map[int]genMetrics{} + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + var rec struct { + Gen int `json:"gen"` + Phase string `json:"phase"` + ValPolicy float64 `json:"val_policy"` + Results []struct { + B string `json:"b"` + Wins int `json:"wins"` + Losses int `json:"losses"` + } `json:"results"` + } + if err := json.Unmarshal([]byte(line), &rec); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + m := out[rec.Gen] + switch rec.Phase { + case "eval": + m.results = nil + for _, r := range rec.Results { + m.results = append(m.results, result{Opp: r.B, Wins: r.Wins, Losses: r.Losses}) + } + case "train": + m.policyLoss = rec.ValPolicy + } + out[rec.Gen] = m + } + return out, nil +} + +func maxOf(xs []float64) float64 { + m := 0.0 + for _, x := range xs { + m = max(m, x) + } + return m +} + +// openingPolicy evaluates the empty board through all eight symmetries and +// averages: what the network "wants" to play first, before any search. +func openingPolicy(model string) ([]float64, float64, error) { + bk, err := nn.NewORTBackend(model, size, nn.ORTOptions{}) + if err != nil { + return nil, 0, err + } + defer bk.Close() + b := game.NewBoard(game.Options{Size: size, Komi: 7.5, Superko: true}) + points, planes := size*size, nn.NumPlanes*size*size + spatial := make([]float32, 8*planes) + global := make([]float32, 8*nn.NumGlobals) + for s := 0; s < 8; s++ { + nn.Encode(b, s, spatial[s*planes:(s+1)*planes], global[s*nn.NumGlobals:(s+1)*nn.NumGlobals]) + } + logits, values, err := bk.Run(spatial, global, 8) + if err != nil { + return nil, 0, err + } + pol := make([]float64, points+1) + value := 0.0 + for s := 0; s < 8; s++ { + row := logits[s*(points+1) : (s+1)*(points+1)] + nn.Softmax(row) + t := nn.Symmetry(size, s) + for p := 0; p < points; p++ { + pol[p] += float64(row[t[p]]) / 8 + } + pol[points] += float64(row[points]) / 8 + value += float64(values[s]) / 8 + } + for i := range pol { + pol[i] = round(pol[i], 4) + } + return pol, (1 + value) / 2, nil +} + +// typicalGame picks the self-play game that was scored (not resigned) with the +// median length, so the final position shows what that generation's Go looks like. +func typicalGame(dir string) (*spGame, error) { + files, err := filepath.Glob(filepath.Join(dir, "*.sgf")) + if err != nil || len(files) == 0 { + return nil, err + } + sort.Strings(files) + type cand struct { + g *sgf.Game + moves []sgf.Move + } + var scored []cand + for _, f := range files { + g, moves, err := readGame(f) + if err != nil { + return nil, err + } + if !strings.HasSuffix(g.Result, "+R") { + scored = append(scored, cand{g, moves}) + } + } + if len(scored) == 0 { + return nil, nil + } + sort.SliceStable(scored, func(i, j int) bool { return len(scored[i].moves) < len(scored[j].moves) }) + c := scored[len(scored)/2] + sp := &spGame{Result: c.g.Result} + for _, m := range c.moves { + sp.Moves = append(sp.Moves, pt(m.Point)) + } + return sp, nil +} diff --git a/docs/results/games/gnugo-l10-vs-gofer.sgf b/docs/results/games/gnugo-l10-vs-gofer.sgf new file mode 100644 index 0000000..ce0b698 --- /dev/null +++ b/docs/results/games/gnugo-l10-vs-gofer.sgf @@ -0,0 +1 @@ +(;FF[4]GM[1]SZ[9]KM[7.5]RU[Chinese]PB[gnugo-l10]PW[gofer-9x9@400]RE[W+12.5];B[bd];W[gg];B[ff];W[ge];B[gf];W[hf];B[fc];W[fe];B[fg];W[ef];B[hg];W[eg];B[gh];W[fh];B[gg];W[ed];B[cg];W[dc];B[hd];W[he];B[eh];W[dh];B[fi];W[dg];B[cb];W[eb];B[fb];W[cf];B[db];W[ec];B[bg];W[bf];B[gd];W[ea];B[cc];W[fa];B[dd];W[de];B[gb];W[ga];B[di];W[ci];B[ei];W[ch];B[cd];W[hb];B[be];W[fd];B[af];W[bh];B[ag];W[ah];B[ae];W[hc];B[ce];W[df];B[if];W[ie];B[ig];W[da];B[bb];W[ca];B[ba];W[ab];B[ac];W[gi];B[hi];W[gc];B[];W[]) diff --git a/internal/arena/players.go b/internal/arena/players.go index c3ba526..01d42f2 100644 --- a/internal/arena/players.go +++ b/internal/arena/players.go @@ -56,6 +56,9 @@ func (p *MCTSPlayer) GenMove(b *game.Board) (game.Point, bool, error) { if p.s.Board().Key() != b.Key() { p.s.Reset(b) } + if p.s.PassWins() { + return game.Pass, false, nil + } if err := p.s.Run(p.Visits); err != nil { return game.Pass, false, err } diff --git a/internal/gtp/gtp.go b/internal/gtp/gtp.go index ed03234..0f84400 100644 --- a/internal/gtp/gtp.go +++ b/internal/gtp/gtp.go @@ -391,6 +391,12 @@ func (e *Engine) genmove(c game.Color) (string, error) { if e.board.GameOver() { e.s.Reset(e.board) } + if e.s.PassWins() { + if err := e.apply(c, game.Pass); err != nil { + return "", err + } + return "pass", nil + } movesLeft := (e.board.NumPoints() - e.board.MoveNum()/2) / 2 if budget := e.clock[c].budget(movesLeft); budget > 0 { // Under time control, search until the budget is spent (at least one diff --git a/internal/mcts/search.go b/internal/mcts/search.go index d3b491c..022a797 100644 --- a/internal/mcts/search.go +++ b/internal/mcts/search.go @@ -419,6 +419,23 @@ func (s *Search) Visits() int { return int(s.root.n) } +// PassWins reports whether the opponent just passed and passing now would end +// the game as a win for the side to move under area scoring. Players use it to +// pass instead of filling their own territory once the game is decided; the +// network rarely proposes pass because self-play only allows it at the end. +func (s *Search) PassWins() bool { + b := s.board + if b == nil { + return false + } + if m, ok := b.LastMove(0); !ok || m != game.Pass { + return false + } + t := b.Clone() + t.PlayUnchecked(game.Pass) + return t.Winner() == b.ToPlay() +} + // BestMove returns the most visited root move (Pass if the game is over). func (s *Search) BestMove() game.Point { ch := s.Children() diff --git a/internal/mcts/search_test.go b/internal/mcts/search_test.go index 660d652..6aeb2e9 100644 --- a/internal/mcts/search_test.go +++ b/internal/mcts/search_test.go @@ -204,3 +204,28 @@ func TestConservativePass(t *testing.T) { t.Fatal("pass must be searchable after the opponent passed") } } + +func TestPassWins(t *testing.T) { + b := game.NewBoard(game.Options{Size: 5, Komi: 0.5}) + place(b, game.Black, b.P(2, 2)) + b.SetToPlay(game.White) + b.Play(game.Pass) // white passes; black owns the board + s := New(DefaultConfig(), nn.Uniform{}) + s.Reset(b) + if !s.PassWins() { + t.Fatal("black should see that passing wins") + } + b2 := game.NewBoard(game.Options{Size: 5, Komi: 7.5}) + b2.Play(game.Pass) // black passes on an empty board; white wins on komi + s.Reset(b2) + if !s.PassWins() { + t.Fatal("white should see that passing wins on komi") + } + b3 := game.NewBoard(game.Options{Size: 5, Komi: 7.5}) + b3.SetToPlay(game.White) + b3.Play(game.Pass) // black to move would lose by passing + s.Reset(b3) + if s.PassWins() { + t.Fatal("passing loses for black here") + } +} diff --git a/scripts/build-site.sh b/scripts/build-site.sh new file mode 100755 index 0000000..8307d86 --- /dev/null +++ b/scripts/build-site.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Build the static site in web/: the in-browser engine (WebAssembly + the +# released network). The page itself (index.html, site.css, site.js) is +# hand-written and needs no build step. Outputs are committed so any static +# host can serve web/ as-is; rerun this after changing the engine or +# models/gofer-9x9.onnx. +# +# scripts/build-site.sh # then: python3 -m http.server -d web 8000 +# +# The page's data (web/data/*.json) comes from a training run instead: +# +# go run -tags onnx ./cmd/sitedata -run runs/9x9 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +GOOS=js GOARCH=wasm go build -trimpath -ldflags="-s -w" -o web/engine/gofer.wasm ./cmd/gofer-wasm +WASM_EXEC="$(go env GOROOT)/lib/wasm/wasm_exec.js" +[[ -f "$WASM_EXEC" ]] || WASM_EXEC="$(go env GOROOT)/misc/wasm/wasm_exec.js" # Go < 1.24 +cp "$WASM_EXEC" web/engine/wasm_exec.js +cp models/gofer-9x9.onnx web/engine/gofer-9x9.onnx + +ls -l web/engine diff --git a/web/apple-touch-icon.png b/web/apple-touch-icon.png index 9a23610..f598612 100644 Binary files a/web/apple-touch-icon.png and b/web/apple-touch-icon.png differ diff --git a/web/data/generations.json b/web/data/generations.json new file mode 100644 index 0000000..b9d1014 --- /dev/null +++ b/web/data/generations.json @@ -0,0 +1 @@ +{"gens":[{"gen":0,"elo":0,"se":0,"opening":[0.0121,0.0122,0.0121,0.0121,0.0121,0.0121,0.0121,0.0122,0.0121,0.0122,0.0123,0.0122,0.0122,0.0123,0.0122,0.0122,0.0123,0.0122,0.0121,0.0122,0.0122,0.0122,0.0122,0.0122,0.0122,0.0122,0.0121,0.0121,0.0122,0.0122,0.0122,0.0122,0.0122,0.0122,0.0122,0.0121,0.0121,0.0123,0.0122,0.0122,0.0122,0.0122,0.0122,0.0123,0.0121,0.0121,0.0122,0.0122,0.0122,0.0122,0.0122,0.0122,0.0122,0.0121,0.0121,0.0122,0.0122,0.0122,0.0122,0.0122,0.0122,0.0122,0.0121,0.0122,0.0123,0.0122,0.0122,0.0123,0.0122,0.0122,0.0123,0.0122,0.0121,0.0122,0.0121,0.0121,0.0121,0.0121,0.0121,0.0122,0.0121,0.0134],"value":0.472,"game":{"result":"W+38.5","moves":[79,46,28,32,37,71,78,52,42,77,59,49,75,34,1,38,24,36,17,76,50,0,12,25,45,60,44,51,33,43,10,16,8,29,55,65,40,20,64,19,63,56,39,13,26,47,74,30,53,80,61,70,67,9,2,27,15,5,11,68,4,21,58,69,62,66,72,14,22,6,18,9,23,3,41,73,37,31,74,54,57,45,75,73,64,0,78,10,75,11,35,7,8,1,53,72,44,28,61,63,26,35,48,74,-1,-1]},"policyLoss":4.413},{"gen":1,"elo":763.4,"se":90.3,"opening":[0.0109,0.0115,0.0116,0.0117,0.0117,0.0117,0.0116,0.0115,0.0109,0.0115,0.0121,0.0124,0.0126,0.0126,0.0126,0.0124,0.0121,0.0115,0.0116,0.0124,0.013,0.0132,0.0133,0.0132,0.013,0.0124,0.0116,0.0117,0.0126,0.0132,0.0135,0.0136,0.0135,0.0132,0.0126,0.0117,0.0117,0.0126,0.0133,0.0136,0.0136,0.0136,0.0133,0.0126,0.0117,0.0117,0.0126,0.0132,0.0135,0.0136,0.0135,0.0132,0.0126,0.0117,0.0116,0.0124,0.013,0.0132,0.0133,0.0132,0.013,0.0124,0.0116,0.0115,0.0121,0.0124,0.0126,0.0126,0.0126,0.0124,0.0121,0.0115,0.0109,0.0115,0.0116,0.0117,0.0117,0.0117,0.0116,0.0115,0.0109,0],"value":0.495,"game":{"result":"W+14.5","moves":[23,50,67,31,68,66,39,76,32,40,57,30,48,58,59,38,22,49,60,51,65,47,75,41,64,29,7,74,73,42,33,15,14,55,13,46,72,12,20,21,43,56,66,24,77,4,63,37,10,34,61,45,17,25,28,35,78,1,52,18,6,5,2,13,62,23,0,26,19,27,70,32,79,44,3,53,54,11,16,80,71,1,2,3,1,8,17,7,-1,-1]},"results":[{"opp":"gen-0000","w":40,"l":0},{"opp":"gnugo-l1","w":0,"l":40},{"opp":"gnugo-l6","w":0,"l":40}],"policyLoss":3.336},{"gen":2,"elo":1176.5,"se":73.8,"opening":[0.0081,0.0092,0.0095,0.0099,0.0098,0.0099,0.0095,0.0092,0.0081,0.0092,0.0131,0.0135,0.0145,0.0145,0.0145,0.0135,0.0131,0.0092,0.0095,0.0135,0.0127,0.014,0.014,0.014,0.0127,0.0135,0.0095,0.0099,0.0145,0.014,0.016,0.0161,0.016,0.014,0.0145,0.0099,0.0098,0.0145,0.014,0.0161,0.0163,0.0161,0.014,0.0145,0.0098,0.0099,0.0145,0.014,0.016,0.0161,0.016,0.014,0.0145,0.0099,0.0095,0.0135,0.0127,0.014,0.014,0.014,0.0127,0.0135,0.0095,0.0092,0.0131,0.0135,0.0145,0.0145,0.0145,0.0135,0.0131,0.0092,0.0081,0.0092,0.0095,0.0099,0.0098,0.0099,0.0095,0.0092,0.0081,0],"value":0.481,"game":{"result":"B+13.5","moves":[38,46,50,49,48,55,57,67,47,40,58,59,68,41,60,37,29,51,59,42,39,31,30,66,65,52,22,28,56,61,19,69,70,21,20,13,12,23,64,21,10,22,16,78,77,24,15,11,14,3,25,27,18,79,71,33,36,62,54,80,71,70,43,2,76,63,1,34,35,73,45,37,17,44,26,5,75,8,7,67,28,55,66,74,46,0,9,6,4,6,5,12,72,73,74,-1,-1]},"results":[{"opp":"gen-0001","w":38,"l":2},{"opp":"gnugo-l1","w":0,"l":40},{"opp":"gnugo-l6","w":0,"l":40}],"policyLoss":3.394},{"gen":3,"elo":1669.8,"se":50.3,"opening":[0.0089,0.0099,0.0094,0.0097,0.0097,0.0097,0.0094,0.0099,0.0089,0.0099,0.0165,0.0151,0.0159,0.0157,0.0159,0.0151,0.0165,0.0099,0.0094,0.0151,0.0121,0.0126,0.0124,0.0126,0.0121,0.0151,0.0094,0.0097,0.0159,0.0126,0.0133,0.013,0.0133,0.0126,0.0159,0.0097,0.0097,0.0157,0.0124,0.013,0.0129,0.013,0.0124,0.0157,0.0097,0.0097,0.0159,0.0126,0.0133,0.013,0.0133,0.0126,0.0159,0.0097,0.0094,0.0151,0.0121,0.0126,0.0124,0.0126,0.0121,0.0151,0.0094,0.0099,0.0165,0.0151,0.0159,0.0157,0.0159,0.0151,0.0165,0.0099,0.0089,0.0099,0.0094,0.0097,0.0097,0.0097,0.0094,0.0099,0.0089,0],"value":0.379,"game":{"result":"W+19.5","moves":[70,26,52,16,43,71,15,33,14,22,41,32,40,34,42,31,13,12,30,39,38,20,48,24,21,29,11,39,3,21,28,37,36,47,46,38,56,19,45,27,18,57,49,55,65,54,36,64,23,10,2,66,45,58,7,1,51,9,17,50,25,4,35,76,78,46,62,74,68,59,80,63,60,67,77,2,65,56,44,5,6,3,73,36,-1,-1]},"results":[{"opp":"gen-0002","w":40,"l":0}],"policyLoss":3.251},{"gen":4,"elo":1742.6,"se":34.8,"opening":[0.0066,0.0079,0.0075,0.0078,0.0078,0.0078,0.0075,0.0079,0.0066,0.0079,0.0177,0.017,0.0177,0.0176,0.0177,0.017,0.0177,0.0079,0.0075,0.017,0.0128,0.0134,0.0132,0.0134,0.0128,0.017,0.0075,0.0078,0.0177,0.0134,0.0142,0.014,0.0142,0.0134,0.0177,0.0078,0.0078,0.0176,0.0132,0.014,0.0137,0.014,0.0132,0.0176,0.0078,0.0078,0.0177,0.0134,0.0142,0.014,0.0142,0.0134,0.0177,0.0078,0.0075,0.017,0.0128,0.0134,0.0132,0.0134,0.0128,0.017,0.0075,0.0079,0.0177,0.017,0.0177,0.0176,0.0177,0.017,0.0177,0.0079,0.0066,0.0079,0.0075,0.0078,0.0078,0.0078,0.0075,0.0079,0.0066,0],"value":0.418,"game":{"result":"W+3.5","moves":[44,23,63,21,34,38,51,33,42,31,24,15,32,13,40,39,49,25,33,29,16,57,67,7,26,47,66,65,64,74,55,56,46,73,37,58,59,68,69,75,28,76,19,20,11,10,9,12,1,5,48,3,72,41,50,2,10,70,60,78,67,77,61,71,62,79,8,17,25,36,8,6,17,27,45,43,53,66,18,36,27,-1,-1]},"results":[{"opp":"gen-0003","w":26,"l":14},{"opp":"gnugo-l1","w":4,"l":36},{"opp":"gnugo-l6","w":3,"l":37}],"policyLoss":3.067},{"gen":5,"elo":1768,"se":39.4,"opening":[0.0053,0.007,0.0062,0.0065,0.0064,0.0065,0.0062,0.007,0.0053,0.007,0.0208,0.0185,0.0199,0.0195,0.0199,0.0185,0.0208,0.007,0.0062,0.0185,0.012,0.0129,0.0125,0.0129,0.012,0.0185,0.0062,0.0065,0.0199,0.0129,0.0142,0.0137,0.0142,0.0129,0.0199,0.0065,0.0064,0.0195,0.0125,0.0137,0.0133,0.0137,0.0125,0.0195,0.0064,0.0065,0.0199,0.0129,0.0142,0.0137,0.0142,0.0129,0.0199,0.0065,0.0062,0.0185,0.012,0.0129,0.0125,0.0129,0.012,0.0185,0.0062,0.007,0.0208,0.0185,0.0199,0.0195,0.0199,0.0185,0.0208,0.007,0.0053,0.007,0.0062,0.0065,0.0064,0.0065,0.0062,0.007,0.0053,0],"value":0.382,"game":{"result":"B+15.5","moves":[55,23,52,31,37,41,43,49,29,39,21,33,13,59,14,15,25,34,16,24,35,7,6,5,60,47,46,57,65,17,67,66,68,56,64,69,70,51,78,61,69,42,22,30,75,20,11,19,10,28,38,76,74,12,3,62,71,26,58,48,18,27,36,50,53,20,19,61,62,4,6,44,5,35,54,73,16,63,8,25,7,1,72,9,0,28,2,79,77,-1,-1]},"results":[{"opp":"gen-0004","w":26,"l":14}],"policyLoss":2.784},{"gen":6,"elo":1882.1,"se":32.7,"opening":[0.0044,0.0063,0.0054,0.0058,0.0057,0.0058,0.0054,0.0063,0.0044,0.0063,0.0207,0.0188,0.0201,0.0195,0.0201,0.0188,0.0207,0.0063,0.0054,0.0188,0.0129,0.0139,0.0133,0.0139,0.0129,0.0188,0.0054,0.0058,0.0201,0.0139,0.0152,0.0144,0.0152,0.0139,0.0201,0.0058,0.0057,0.0195,0.0133,0.0144,0.0137,0.0144,0.0133,0.0195,0.0057,0.0058,0.0201,0.0139,0.0152,0.0144,0.0152,0.0139,0.0201,0.0058,0.0054,0.0188,0.0129,0.0139,0.0133,0.0139,0.0129,0.0188,0.0054,0.0063,0.0207,0.0188,0.0201,0.0195,0.0201,0.0188,0.0207,0.0063,0.0044,0.0063,0.0054,0.0058,0.0057,0.0058,0.0054,0.0063,0.0044,0],"value":0.503,"game":{"result":"B+0.5","moves":[12,31,10,41,30,22,39,51,59,60,69,70,68,49,48,21,20,13,58,40,57,61,4,3,2,5,3,14,79,33,29,43,64,15,78,25,71,62,80,50,28,1,11,23,55,7,46,53,76,35,75,17,56,9,19,73,0,74,63,1,65,18,0,66,27,45,72,73,38,9,18,36,54,-1,74,-1,67,-1,-1]},"results":[{"opp":"gen-0005","w":31,"l":9},{"opp":"gnugo-l1","w":1,"l":39},{"opp":"gnugo-l6","w":6,"l":34}],"policyLoss":2.64},{"gen":7,"elo":1975.2,"se":39.2,"opening":[0.0038,0.0064,0.0053,0.0056,0.0055,0.0056,0.0053,0.0064,0.0038,0.0064,0.0217,0.0193,0.0212,0.021,0.0212,0.0193,0.0217,0.0064,0.0053,0.0193,0.0114,0.0128,0.0125,0.0128,0.0114,0.0193,0.0053,0.0056,0.0212,0.0128,0.0148,0.0144,0.0148,0.0128,0.0212,0.0056,0.0055,0.021,0.0125,0.0144,0.014,0.0144,0.0125,0.021,0.0055,0.0056,0.0212,0.0128,0.0148,0.0144,0.0148,0.0128,0.0212,0.0056,0.0053,0.0193,0.0114,0.0128,0.0125,0.0128,0.0114,0.0193,0.0053,0.0064,0.0217,0.0193,0.0212,0.021,0.0212,0.0193,0.0217,0.0064,0.0038,0.0064,0.0053,0.0056,0.0055,0.0056,0.0053,0.0064,0.0038,0],"value":0.437,"game":{"result":"B+1.5","moves":[32,29,21,39,12,47,50,40,42,49,58,59,60,68,69,67,19,31,22,23,14,24,15,33,41,43,51,34,57,56,66,65,77,20,11,16,76,10,1,9,18,28,0,55,52,48,74,73,75,27,44,25,30,13,4,6,53,35,10,7,5,37,70,64,62,3,13,17,2,45,59,78,79,63,71,68,67,-1,-1]},"results":[{"opp":"gen-0006","w":27,"l":13}],"policyLoss":2.656},{"gen":8,"elo":2015.5,"se":29.2,"opening":[0.003,0.0046,0.0037,0.0041,0.004,0.0041,0.0037,0.0046,0.003,0.0046,0.0244,0.0199,0.0243,0.0235,0.0243,0.0199,0.0244,0.0046,0.0037,0.0199,0.0106,0.0126,0.012,0.0126,0.0106,0.0199,0.0037,0.0041,0.0243,0.0126,0.0157,0.0149,0.0157,0.0126,0.0243,0.0041,0.004,0.0235,0.012,0.0149,0.0141,0.0149,0.012,0.0235,0.004,0.0041,0.0243,0.0126,0.0157,0.0149,0.0157,0.0126,0.0243,0.0041,0.0037,0.0199,0.0106,0.0126,0.012,0.0126,0.0106,0.0199,0.0037,0.0046,0.0244,0.0199,0.0243,0.0235,0.0243,0.0199,0.0244,0.0046,0.003,0.0046,0.0037,0.0041,0.004,0.0041,0.0037,0.0046,0.003,0],"value":0.386,"game":{"result":"W+12.5","moves":[52,42,50,22,40,60,70,51,69,41,59,49,58,48,31,32,30,57,23,24,14,68,67,66,77,21,29,13,15,20,28,25,43,16,47,19,33,34,61,33,55,64,4,65,3,6,5,7,10,2,35,12,18,46,37,45,54,56,36,63,74,75,39,1,76,73,9,11,44,26,23,4,17,14,53,8,71,0,27,46,45,79,78,-1,80,-1,-1]},"results":[{"opp":"gen-0007","w":24,"l":16},{"opp":"gnugo-l1","w":8,"l":32},{"opp":"gnugo-l6","w":8,"l":32}],"policyLoss":2.775},{"gen":9,"elo":1974.9,"se":40.3,"opening":[0.0029,0.0047,0.0038,0.0041,0.004,0.0041,0.0038,0.0047,0.0029,0.0047,0.0243,0.0195,0.0238,0.023,0.0238,0.0195,0.0243,0.0047,0.0038,0.0195,0.0105,0.013,0.0123,0.013,0.0105,0.0195,0.0038,0.0041,0.0238,0.013,0.0161,0.0153,0.0161,0.013,0.0238,0.0041,0.004,0.023,0.0123,0.0153,0.0146,0.0153,0.0123,0.023,0.004,0.0041,0.0238,0.013,0.0161,0.0153,0.0161,0.013,0.0238,0.0041,0.0038,0.0195,0.0105,0.013,0.0123,0.013,0.0105,0.0195,0.0038,0.0047,0.0243,0.0195,0.0238,0.023,0.0238,0.0195,0.0243,0.0047,0.0029,0.0047,0.0038,0.0041,0.004,0.0041,0.0038,0.0047,0.0029,0],"value":0.379,"game":{"result":"B+2.5","moves":[46,23,68,39,37,41,55,13,29,31,51,21,43,33,34,25,50,57,49,48,11,58,59,67,20,65,64,77,78,76,69,40,42,73,74,75,56,12,2,47,38,35,44,26,3,15,4,5,63,30,19,7,70,17,36,60,52,27,62,9,18,1,10,80,0,79,71,54,79,72,74,66,73,-1,45,-1,28,-1,-1]},"results":[{"opp":"gen-0008","w":16,"l":24}],"policyLoss":2.747},{"gen":10,"elo":2130.7,"se":28,"opening":[0.0019,0.0031,0.0025,0.0027,0.0027,0.0027,0.0025,0.0031,0.0019,0.0031,0.0211,0.0188,0.028,0.0265,0.028,0.0188,0.0211,0.0031,0.0025,0.0188,0.0086,0.0122,0.0116,0.0122,0.0086,0.0188,0.0025,0.0027,0.028,0.0122,0.0202,0.0186,0.0202,0.0122,0.028,0.0027,0.0027,0.0265,0.0116,0.0186,0.0173,0.0186,0.0116,0.0265,0.0027,0.0027,0.028,0.0122,0.0202,0.0186,0.0202,0.0122,0.028,0.0027,0.0025,0.0188,0.0086,0.0122,0.0116,0.0122,0.0086,0.0188,0.0025,0.0031,0.0211,0.0188,0.028,0.0265,0.028,0.0188,0.0211,0.0031,0.0019,0.0031,0.0025,0.0027,0.0027,0.0027,0.0025,0.0031,0.0019,0],"value":0.493,"game":{"result":"W+4.5","moves":[67,33,40,51,32,42,48,30,23,24,15,14,13,16,5,38,25,34,46,47,56,37,55,20,11,10,12,50,19,28,18,27,21,9,69,58,57,68,77,59,61,52,31,62,70,66,76,78,79,75,78,71,65,60,74,39,49,7,1,53,45,2,3,80,6,36,26,35,19,18,41,17,25,0,2,26,66,64,73,43,54,72,63,-1,-1]},"results":[{"opp":"gen-0009","w":27,"l":13},{"opp":"rollout","w":40,"l":0},{"opp":"gnugo-l1","w":19,"l":21},{"opp":"gnugo-l6","w":12,"l":28}],"policyLoss":2.455},{"gen":11,"elo":2184.4,"se":38.8,"opening":[0.0036,0.0052,0.0042,0.0046,0.0044,0.0046,0.0042,0.0052,0.0036,0.0052,0.0184,0.0183,0.0228,0.022,0.0228,0.0183,0.0184,0.0052,0.0042,0.0183,0.0091,0.0137,0.0131,0.0137,0.0091,0.0183,0.0042,0.0046,0.0228,0.0137,0.0194,0.0183,0.0194,0.0137,0.0228,0.0046,0.0044,0.022,0.0131,0.0183,0.0172,0.0183,0.0131,0.022,0.0044,0.0046,0.0228,0.0137,0.0194,0.0183,0.0194,0.0137,0.0228,0.0046,0.0042,0.0183,0.0091,0.0137,0.0131,0.0137,0.0091,0.0183,0.0042,0.0052,0.0184,0.0183,0.0228,0.022,0.0228,0.0183,0.0184,0.0052,0.0036,0.0052,0.0042,0.0046,0.0044,0.0046,0.0042,0.0052,0.0036,0],"value":0.448,"game":{"result":"B+9.5","moves":[12,57,19,39,37,67,32,49,41,50,51,42,43,33,24,34,25,52,35,60,42,29,28,20,11,10,38,30,61,70,47,62,55,22,13,14,23,65,64,1,31,21,48,40,9,2,15,56,18,73,0,63,54,45,46,36,3,59,44,10,1,74,53,61,72,5,63,6,7,16,17,77,27,75,4,79,14,45,33,71,36,78,6,68,-1,-1]},"results":[{"opp":"gen-0010","w":22,"l":18}],"policyLoss":2.534},{"gen":12,"elo":2238.1,"se":27.3,"opening":[0.0022,0.0035,0.0029,0.0033,0.0032,0.0033,0.0029,0.0035,0.0022,0.0035,0.0185,0.0184,0.024,0.0236,0.024,0.0184,0.0185,0.0035,0.0029,0.0184,0.0085,0.0133,0.0126,0.0133,0.0085,0.0184,0.0029,0.0033,0.024,0.0133,0.0232,0.0223,0.0232,0.0133,0.024,0.0033,0.0032,0.0236,0.0126,0.0223,0.0209,0.0223,0.0126,0.0236,0.0032,0.0033,0.024,0.0133,0.0232,0.0223,0.0232,0.0133,0.024,0.0033,0.0029,0.0184,0.0085,0.0133,0.0126,0.0133,0.0085,0.0184,0.0029,0.0035,0.0185,0.0184,0.024,0.0236,0.024,0.0184,0.0185,0.0035,0.0022,0.0035,0.0029,0.0033,0.0032,0.0033,0.0029,0.0035,0.0022,0],"value":0.444,"game":{"result":"W+22.5","moves":[61,23,15,24,12,16,32,14,22,42,40,50,58,49,48,57,59,47,39,56,51,38,41,30,31,29,33,34,43,11,13,67,5,6,4,19,7,8,35,25,2,1,3,10,26,66,17,44,26,35,64,55,74,65,54,75,45,73,63,37,72,74,36,27,18,28,77,17,21,20,9,46,71,63,76,45,53,68,78,69,79,60,70,50,49,0,18,9,-1,-1]},"results":[{"opp":"gen-0011","w":22,"l":18},{"opp":"rollout","w":40,"l":0},{"opp":"gnugo-l1","w":26,"l":14},{"opp":"gnugo-l6","w":20,"l":20}],"policyLoss":2.581},{"gen":13,"elo":2297,"se":39.1,"opening":[0.0024,0.0037,0.0028,0.0032,0.0031,0.0032,0.0028,0.0037,0.0024,0.0037,0.0199,0.0181,0.0245,0.0235,0.0245,0.0181,0.0199,0.0037,0.0028,0.0181,0.0073,0.0128,0.0116,0.0128,0.0073,0.0181,0.0028,0.0032,0.0245,0.0128,0.0239,0.0226,0.0239,0.0128,0.0245,0.0032,0.0031,0.0235,0.0116,0.0226,0.0211,0.0226,0.0116,0.0235,0.0031,0.0032,0.0245,0.0128,0.0239,0.0226,0.0239,0.0128,0.0245,0.0032,0.0028,0.0181,0.0073,0.0128,0.0116,0.0128,0.0073,0.0181,0.0028,0.0037,0.0199,0.0181,0.0245,0.0235,0.0245,0.0181,0.0199,0.0037,0.0024,0.0037,0.0028,0.0032,0.0031,0.0032,0.0028,0.0037,0.0024,0],"value":0.427,"game":{"result":"B+1.5","moves":[32,59,40,51,30,67,48,69,42,13,12,22,21,24,43,34,52,61,66,65,57,75,64,56,16,25,23,15,14,5,55,47,46,38,37,4,3,6,50,60,35,26,44,73,33,62,29,76,58,63,54,53,31,28,72,74,7,19,10,36,18,27,9,39,49,63,20,45,17,71,11,68,15,55,13,79,25,78,5,37,1,-1,-1]},"results":[{"opp":"gen-0012","w":25,"l":15}],"policyLoss":2.471},{"gen":14,"elo":2218.6,"se":27.2,"opening":[0.0024,0.0037,0.0029,0.0033,0.0031,0.0033,0.0029,0.0037,0.0024,0.0037,0.0191,0.0185,0.0268,0.025,0.0268,0.0185,0.0191,0.0037,0.0029,0.0185,0.0067,0.0112,0.0098,0.0112,0.0067,0.0185,0.0029,0.0033,0.0268,0.0112,0.0249,0.0216,0.0249,0.0112,0.0268,0.0033,0.0031,0.025,0.0098,0.0216,0.0185,0.0216,0.0098,0.025,0.0031,0.0033,0.0268,0.0112,0.0249,0.0216,0.0249,0.0112,0.0268,0.0033,0.0029,0.0185,0.0067,0.0112,0.0098,0.0112,0.0067,0.0185,0.0029,0.0037,0.0191,0.0185,0.0268,0.025,0.0268,0.0185,0.0191,0.0037,0.0024,0.0037,0.0029,0.0033,0.0031,0.0033,0.0029,0.0037,0.0024,0],"value":0.422,"game":{"result":"W+6.5","moves":[46,40,48,30,22,20,28,12,14,42,50,41,51,49,58,57,66,56,47,67,59,65,68,24,76,55,29,21,19,52,15,16,25,34,7,26,10,13,23,61,54,63,45,69,60,70,74,5,6,4,2,31,11,32,3,78,77,8,64,62,39,43,9,7,72,44,38,71,27,65,56,80,15,1,6,14,0,15,36,23,-1,-1]},"results":[{"opp":"gen-0013","w":17,"l":23},{"opp":"rollout","w":40,"l":0},{"opp":"gnugo-l1","w":24,"l":16},{"opp":"gnugo-l6","w":17,"l":23}],"policyLoss":2.496},{"gen":15,"elo":2242.6,"se":38.7,"opening":[0.0023,0.0032,0.0027,0.003,0.0029,0.003,0.0027,0.0032,0.0023,0.0032,0.0173,0.0172,0.0253,0.0243,0.0253,0.0172,0.0173,0.0032,0.0027,0.0172,0.0075,0.0125,0.0116,0.0125,0.0075,0.0172,0.0027,0.003,0.0253,0.0125,0.0264,0.0243,0.0264,0.0125,0.0253,0.003,0.0029,0.0243,0.0116,0.0243,0.0222,0.0243,0.0116,0.0243,0.0029,0.003,0.0253,0.0125,0.0264,0.0243,0.0264,0.0125,0.0253,0.003,0.0027,0.0172,0.0075,0.0125,0.0116,0.0125,0.0075,0.0172,0.0027,0.0032,0.0173,0.0172,0.0253,0.0243,0.0253,0.0172,0.0173,0.0032,0.0023,0.0032,0.0027,0.003,0.0029,0.003,0.0027,0.0032,0.0023,0],"value":0.442,"game":{"result":"W+2.5","moves":[34,48,28,50,40,32,31,58,41,22,23,21,30,20,29,14,24,19,56,66,65,47,46,51,15,13,61,52,60,68,69,62,71,53,77,79,75,59,76,78,18,9,27,10,5,57,74,42,33,4,6,70,38,44,35,2,67,3,43,49,39,69,55,80,26,1,7,11,17,60,37,16,54,63,25,73,45,64,72,63,64,-1,-1]},"results":[{"opp":"gen-0014","w":23,"l":17}],"policyLoss":2.458},{"gen":16,"elo":2301.1,"se":27.6,"opening":[0.0022,0.0035,0.0029,0.0032,0.003,0.0032,0.0029,0.0035,0.0022,0.0035,0.0188,0.0177,0.0258,0.025,0.0258,0.0177,0.0188,0.0035,0.0029,0.0177,0.0073,0.012,0.0113,0.012,0.0073,0.0177,0.0029,0.0032,0.0258,0.012,0.024,0.0229,0.024,0.012,0.0258,0.0032,0.003,0.025,0.0113,0.0229,0.0219,0.0229,0.0113,0.025,0.003,0.0032,0.0258,0.012,0.024,0.0229,0.024,0.012,0.0258,0.0032,0.0029,0.0177,0.0073,0.012,0.0113,0.012,0.0073,0.0177,0.0029,0.0035,0.0188,0.0177,0.0258,0.025,0.0258,0.0177,0.0188,0.0035,0.0022,0.0035,0.0029,0.0032,0.003,0.0032,0.0029,0.0035,0.0022,0],"value":0.435,"game":{"result":"W+0.5","moves":[46,24,34,32,50,30,40,42,31,22,21,20,12,29,13,23,11,10,51,43,52,14,39,38,47,48,57,41,49,37,44,4,25,16,17,45,7,15,54,36,55,6,26,8,19,2,28,3,7,12,65,8,53,7,70,27,67,33,35,9,75,0,68,18,63,19,60,74,78,73,72,79,71,76,77,61,62,59,64,73,74,-1,80,-1,58,-1,-1]},"results":[{"opp":"gen-0015","w":25,"l":15},{"opp":"rollout","w":40,"l":0},{"opp":"gnugo-l1","w":26,"l":14},{"opp":"gnugo-l6","w":23,"l":17}],"policyLoss":2.392},{"gen":17,"elo":2318,"se":54.3,"opening":[0.0019,0.0032,0.0027,0.003,0.0028,0.003,0.0027,0.0032,0.0019,0.0032,0.0166,0.017,0.0254,0.0253,0.0254,0.017,0.0166,0.0032,0.0027,0.017,0.0066,0.0113,0.0106,0.0113,0.0066,0.017,0.0027,0.003,0.0254,0.0113,0.0282,0.0266,0.0282,0.0113,0.0254,0.003,0.0028,0.0253,0.0106,0.0266,0.0253,0.0266,0.0106,0.0253,0.0028,0.003,0.0254,0.0113,0.0282,0.0266,0.0282,0.0113,0.0254,0.003,0.0027,0.017,0.0066,0.0113,0.0106,0.0113,0.0066,0.017,0.0027,0.0032,0.0166,0.017,0.0254,0.0253,0.0254,0.017,0.0166,0.0032,0.0019,0.0032,0.0027,0.003,0.0028,0.003,0.0027,0.0032,0.0019,0],"value":0.425,"game":{"result":"B+1.5","moves":[48,23,15,13,40,31,30,20,32,33,22,24,14,21,31,12,42,16,25,34,43,26,29,56,57,66,67,65,19,10,28,47,9,5,1,11,46,55,37,68,76,58,59,60,49,69,51,61,52,2,77,78,62,71,53,79,75,74,58,45,36,54,38,63,73,0,18,1,64,72,64,35,73,44,50,4,65,6,56,14,54,17,72,7,-1,-1]},"results":[{"opp":"gen-0016","w":21,"l":19}]}],"anchors":{"gnugo-l1":2180.1,"gnugo-l6":2240.7,"rollout":1448}} \ No newline at end of file diff --git a/web/data/hero.json b/web/data/hero.json new file mode 100644 index 0000000..f6ad7a6 --- /dev/null +++ b/web/data/hero.json @@ -0,0 +1 @@ +{"black":"gnugo-l10","white":"gofer-9x9@400","result":"W+12.5","komi":7.5,"visits":800,"moves":[{"c":"B","p":28,"bw":0.442,"n":800,"top":[{"p":41,"n":88,"w":0.461,"pr":0.025},{"p":39,"n":80,"w":0.459,"pr":0.027},{"p":31,"n":79,"w":0.458,"pr":0.027},{"p":49,"n":74,"w":0.456,"pr":0.027},{"p":48,"n":71,"w":0.457,"pr":0.029}],"pv":[41,48,31,58,38]},{"c":"W","p":60,"bw":0.421,"n":808,"top":[{"p":50,"n":63,"w":0.588,"pr":0.047},{"p":51,"n":58,"w":0.59,"pr":0.036},{"p":42,"n":52,"w":0.587,"pr":0.036},{"p":32,"n":48,"w":0.585,"pr":0.04},{"p":58,"n":45,"w":0.589,"pr":0.033}],"pv":[50,40,42,32,24]},{"c":"B","p":50,"bw":0.426,"n":816,"top":[{"p":50,"n":104,"w":0.443,"pr":0.054},{"p":32,"n":70,"w":0.441,"pr":0.026},{"p":41,"n":64,"w":0.438,"pr":0.03},{"p":49,"n":52,"w":0.436,"pr":0.029},{"p":30,"n":51,"w":0.435,"pr":0.032}],"pv":[50,42,51,52,41,33]},{"c":"W","p":42,"bw":0.441,"n":904,"top":[{"p":42,"n":111,"w":0.56,"pr":0.124},{"p":58,"n":85,"w":0.556,"pr":0.108},{"p":59,"n":84,"w":0.57,"pr":0.055},{"p":51,"n":79,"w":0.567,"pr":0.067},{"p":57,"n":56,"w":0.573,"pr":0.018}],"pv":[42,41,32,51,52,43]},{"c":"B","p":51,"bw":0.451,"n":911,"top":[{"p":51,"n":420,"w":0.472,"pr":0.085},{"p":41,"n":148,"w":0.464,"pr":0.056},{"p":32,"n":72,"w":0.436,"pr":0.11},{"p":59,"n":52,"w":0.451,"pr":0.041},{"p":68,"n":32,"w":0.413,"pr":0.087}],"pv":[51,52,41,33,59,32]},{"c":"W","p":52,"bw":0.478,"n":1220,"top":[{"p":52,"n":826,"w":0.524,"pr":0.561},{"p":41,"n":125,"w":0.537,"pr":0.047},{"p":59,"n":102,"w":0.531,"pr":0.043},{"p":49,"n":44,"w":0.541,"pr":0.015},{"p":32,"n":25,"w":0.491,"pr":0.05}],"pv":[52,41,33,43,61,32]},{"c":"B","p":23,"bw":0.477,"n":1626,"top":[{"p":41,"n":1084,"w":0.487,"pr":0.301},{"p":59,"n":202,"w":0.471,"pr":0.173},{"p":61,"n":198,"w":0.476,"pr":0.115},{"p":43,"n":69,"w":0.445,"pr":0.117},{"p":32,"n":15,"w":0.382,"pr":0.062}],"pv":[41,33,43,61,32,34]},{"c":"W","p":41,"bw":0.4,"n":802,"top":[{"p":59,"n":299,"w":0.621,"pr":0.107},{"p":41,"n":254,"w":0.608,"pr":0.189},{"p":32,"n":42,"w":0.586,"pr":0.073},{"p":49,"n":27,"w":0.588,"pr":0.041},{"p":57,"n":22,"w":0.605,"pr":0.015}],"pv":[59,41,33,32,58,61]},{"c":"B","p":59,"bw":0.401,"n":1054,"top":[{"p":59,"n":617,"w":0.413,"pr":0.296},{"p":49,"n":345,"w":0.398,"pr":0.29},{"p":61,"n":24,"w":0.373,"pr":0.046},{"p":40,"n":16,"w":0.305,"pr":0.082},{"p":58,"n":9,"w":0.366,"pr":0.021}],"pv":[59,49,61,69,43,53]},{"c":"W","p":49,"bw":0.417,"n":1417,"top":[{"p":49,"n":742,"w":0.597,"pr":0.158},{"p":69,"n":101,"w":0.586,"pr":0.05},{"p":68,"n":92,"w":0.588,"pr":0.053},{"p":40,"n":72,"w":0.542,"pr":0.156},{"p":31,"n":64,"w":0.555,"pr":0.113}],"pv":[49,61,58,68,67,43]},{"c":"B","p":61,"bw":0.41,"n":1542,"top":[{"p":61,"n":1107,"w":0.426,"pr":0.125},{"p":58,"n":212,"w":0.397,"pr":0.216},{"p":69,"n":107,"w":0.371,"pr":0.205},{"p":68,"n":55,"w":0.34,"pr":0.163},{"p":67,"n":10,"w":0.375,"pr":0.019}],"pv":[61,58,68,31,43,34]},{"c":"W","p":58,"bw":0.412,"n":1907,"top":[{"p":58,"n":1362,"w":0.603,"pr":0.146},{"p":69,"n":309,"w":0.546,"pr":0.52},{"p":68,"n":91,"w":0.58,"pr":0.033},{"p":43,"n":39,"w":0.555,"pr":0.052},{"p":31,"n":17,"w":0.537,"pr":0.035}],"pv":[58,68,31,43,34,53]},{"c":"B","p":69,"bw":0.392,"n":2162,"top":[{"p":68,"n":1825,"w":0.402,"pr":0.283},{"p":69,"n":323,"w":0.345,"pr":0.596},{"p":43,"n":3,"w":0.153,"pr":0.022},{"p":53,"n":1,"w":0.226,"pr":0.005},{"p":30,"n":1,"w":0.188,"pr":0.002}],"pv":[68,31,43,34,53,22]},{"c":"W","p":68,"bw":0.339,"n":1123,"top":[{"p":68,"n":886,"w":0.678,"pr":0.152},{"p":31,"n":103,"w":0.641,"pr":0.152},{"p":39,"n":23,"w":0.594,"pr":0.084},{"p":40,"n":15,"w":0.531,"pr":0.1},{"p":38,"n":10,"w":0.612,"pr":0.027}],"pv":[68,60,31,22,21,30]},{"c":"B","p":60,"bw":0.327,"n":1686,"top":[{"p":60,"n":1674,"w":0.328,"pr":0.906},{"p":43,"n":4,"w":0.212,"pr":0.017},{"p":67,"n":3,"w":0.186,"pr":0.019},{"p":77,"n":2,"w":0.161,"pr":0.01},{"p":40,"n":1,"w":0.2,"pr":0.002}],"pv":[60,31,22,21,30,39]},{"c":"W","p":31,"bw":0.33,"n":2474,"top":[{"p":31,"n":1305,"w":0.683,"pr":0.125},{"p":38,"n":284,"w":0.68,"pr":0.025},{"p":39,"n":269,"w":0.674,"pr":0.065},{"p":40,"n":84,"w":0.632,"pr":0.129},{"p":67,"n":66,"w":0.629,"pr":0.106}],"pv":[31,22,21,30,39,29]},{"c":"B","p":56,"bw":0.32,"n":2105,"top":[{"p":22,"n":1559,"w":0.335,"pr":0.052},{"p":67,"n":151,"w":0.277,"pr":0.269},{"p":43,"n":72,"w":0.27,"pr":0.147},{"p":12,"n":56,"w":0.324,"pr":0.013},{"p":21,"n":44,"w":0.317,"pr":0.021}],"pv":[22,21,30,39,29,12]},{"c":"W","p":21,"bw":0.262,"n":806,"top":[{"p":21,"n":220,"w":0.759,"pr":0.074},{"p":38,"n":188,"w":0.76,"pr":0.039},{"p":22,"n":76,"w":0.741,"pr":0.061},{"p":39,"n":47,"w":0.727,"pr":0.08},{"p":66,"n":34,"w":0.716,"pr":0.071}],"pv":[21,11,13,43,34,53]},{"c":"B","p":34,"bw":0.242,"n":1020,"top":[{"p":20,"n":195,"w":0.261,"pr":0.028},{"p":67,"n":143,"w":0.237,"pr":0.17},{"p":43,"n":132,"w":0.237,"pr":0.153},{"p":22,"n":114,"w":0.264,"pr":0.019},{"p":11,"n":81,"w":0.258,"pr":0.017}],"pv":[20,11,10,13,43,34]},{"c":"W","p":43,"bw":0.226,"n":817,"top":[{"p":43,"n":649,"w":0.796,"pr":0.148},{"p":33,"n":33,"w":0.73,"pr":0.099},{"p":38,"n":17,"w":0.739,"pr":0.043},{"p":39,"n":9,"w":0.652,"pr":0.066},{"p":65,"n":7,"w":0.755,"pr":0.01}],"pv":[43,67,66,77,57,65]},{"c":"B","p":67,"bw":0.209,"n":1449,"top":[{"p":67,"n":670,"w":0.216,"pr":0.241},{"p":22,"n":299,"w":0.23,"pr":0.015},{"p":13,"n":118,"w":0.216,"pr":0.036},{"p":33,"n":65,"w":0.175,"pr":0.125},{"p":20,"n":46,"w":0.213,"pr":0.023}],"pv":[67,66,77,57,65,47]},{"c":"W","p":66,"bw":0.221,"n":1470,"top":[{"p":66,"n":759,"w":0.793,"pr":0.169},{"p":38,"n":141,"w":0.789,"pr":0.034},{"p":13,"n":96,"w":0.787,"pr":0.034},{"p":33,"n":73,"w":0.757,"pr":0.102},{"p":11,"n":57,"w":0.786,"pr":0.014}],"pv":[66,77,57,65,47,46]},{"c":"B","p":77,"bw":0.207,"n":1559,"top":[{"p":77,"n":1235,"w":0.219,"pr":0.23},{"p":65,"n":41,"w":0.154,"pr":0.105},{"p":33,"n":38,"w":0.167,"pr":0.077},{"p":22,"n":30,"w":0.209,"pr":0.016},{"p":39,"n":23,"w":0.182,"pr":0.034}],"pv":[77,57,65,47,46,38]},{"c":"W","p":57,"bw":0.216,"n":2035,"top":[{"p":57,"n":1170,"w":0.798,"pr":0.158},{"p":24,"n":155,"w":0.79,"pr":0.03},{"p":29,"n":97,"w":0.781,"pr":0.042},{"p":13,"n":87,"w":0.782,"pr":0.04},{"p":33,"n":71,"w":0.75,"pr":0.105}],"pv":[57,13,12,65,39,47]},{"c":"B","p":11,"bw":0.2,"n":1970,"top":[{"p":22,"n":451,"w":0.215,"pr":0.02},{"p":13,"n":387,"w":0.212,"pr":0.04},{"p":65,"n":281,"w":0.201,"pr":0.134},{"p":47,"n":167,"w":0.201,"pr":0.078},{"p":33,"n":98,"w":0.186,"pr":0.093}],"pv":[22,20,65,47,46,38]},{"c":"W","p":13,"bw":0.205,"n":852,"top":[{"p":20,"n":166,"w":0.814,"pr":0.058},{"p":13,"n":166,"w":0.812,"pr":0.077},{"p":12,"n":131,"w":0.814,"pr":0.059},{"p":47,"n":83,"w":0.797,"pr":0.077},{"p":65,"n":41,"w":0.776,"pr":0.075}],"pv":[13,65,39,47,38,37]},{"c":"B","p":14,"bw":0.184,"n":966,"top":[{"p":14,"n":181,"w":0.199,"pr":0.081},{"p":22,"n":127,"w":0.205,"pr":0.023},{"p":65,"n":104,"w":0.179,"pr":0.126},{"p":12,"n":97,"w":0.197,"pr":0.038},{"p":20,"n":70,"w":0.187,"pr":0.066}],"pv":[14,47,46,55,38,65]},{"c":"W","p":47,"bw":0.196,"n":981,"top":[{"p":47,"n":220,"w":0.821,"pr":0.06},{"p":38,"n":209,"w":0.833,"pr":0.035},{"p":20,"n":86,"w":0.811,"pr":0.048},{"p":12,"n":52,"w":0.807,"pr":0.034},{"p":25,"n":51,"w":0.807,"pr":0.037}],"pv":[47,20,25,24,33,16]},{"c":"B","p":12,"bw":0.195,"n":1020,"top":[{"p":20,"n":513,"w":0.214,"pr":0.083},{"p":46,"n":143,"w":0.202,"pr":0.084},{"p":22,"n":75,"w":0.19,"pr":0.075},{"p":12,"n":49,"w":0.175,"pr":0.088},{"p":33,"n":23,"w":0.185,"pr":0.029}],"pv":[20,12,46,25,38,65]},{"c":"W","p":22,"bw":0.183,"n":849,"top":[{"p":22,"n":692,"w":0.836,"pr":0.229},{"p":20,"n":41,"w":0.785,"pr":0.099},{"p":15,"n":11,"w":0.791,"pr":0.025},{"p":33,"n":9,"w":0.736,"pr":0.048},{"p":29,"n":8,"w":0.711,"pr":0.048}],"pv":[22,46,38,37,55,4]},{"c":"B","p":55,"bw":0.172,"n":1492,"top":[{"p":46,"n":953,"w":0.188,"pr":0.06},{"p":4,"n":209,"w":0.17,"pr":0.135},{"p":20,"n":82,"w":0.141,"pr":0.146},{"p":24,"n":25,"w":0.154,"pr":0.032},{"p":38,"n":14,"w":0.142,"pr":0.027}],"pv":[46,38,37,55,4,20]},{"c":"W","p":46,"bw":0.139,"n":814,"top":[{"p":46,"n":242,"w":0.885,"pr":0.088},{"p":37,"n":102,"w":0.881,"pr":0.046},{"p":20,"n":88,"w":0.867,"pr":0.07},{"p":33,"n":54,"w":0.862,"pr":0.054},{"p":38,"n":40,"w":0.866,"pr":0.03}],"pv":[46,4,20,19,10,1]},{"c":"B","p":33,"bw":0.118,"n":1042,"top":[{"p":4,"n":479,"w":0.134,"pr":0.1},{"p":65,"n":112,"w":0.115,"pr":0.116},{"p":20,"n":96,"w":0.114,"pr":0.107},{"p":37,"n":40,"w":0.115,"pr":0.039},{"p":33,"n":34,"w":0.108,"pr":0.046}],"pv":[4,20,19,10,1,9]},{"c":"W","p":4,"bw":0.116,"n":834,"top":[{"p":20,"n":148,"w":0.906,"pr":0.065},{"p":4,"n":147,"w":0.904,"pr":0.084},{"p":5,"n":105,"w":0.908,"pr":0.037},{"p":32,"n":41,"w":0.872,"pr":0.059},{"p":37,"n":37,"w":0.891,"pr":0.022}],"pv":[20,19,10,1,9,18]},{"c":"B","p":20,"bw":0.096,"n":947,"top":[{"p":65,"n":203,"w":0.104,"pr":0.144},{"p":20,"n":162,"w":0.101,"pr":0.136},{"p":37,"n":112,"w":0.11,"pr":0.044},{"p":5,"n":58,"w":0.091,"pr":0.075},{"p":32,"n":55,"w":0.092,"pr":0.067}],"pv":[65,75,20,32,5,16]},{"c":"W","p":5,"bw":0.106,"n":962,"top":[{"p":16,"n":113,"w":0.908,"pr":0.058},{"p":37,"n":104,"w":0.908,"pr":0.04},{"p":5,"n":90,"w":0.897,"pr":0.08},{"p":32,"n":67,"w":0.899,"pr":0.057},{"p":65,"n":60,"w":0.901,"pr":0.041}],"pv":[16,25,15,5,32,24]},{"c":"B","p":30,"bw":0.101,"n":890,"top":[{"p":65,"n":318,"w":0.111,"pr":0.199},{"p":39,"n":96,"w":0.119,"pr":0.034},{"p":37,"n":52,"w":0.108,"pr":0.039},{"p":45,"n":37,"w":0.097,"pr":0.05},{"p":15,"n":36,"w":0.092,"pr":0.054}],"pv":[65,75,74,15,16,6]},{"c":"W","p":39,"bw":0.081,"n":810,"top":[{"p":39,"n":133,"w":0.928,"pr":0.143},{"p":16,"n":88,"w":0.929,"pr":0.068},{"p":15,"n":88,"w":0.923,"pr":0.088},{"p":32,"n":61,"w":0.921,"pr":0.062},{"p":25,"n":56,"w":0.909,"pr":0.094}],"pv":[39,29,15,16,6]},{"c":"B","p":15,"bw":0.071,"n":933,"top":[{"p":29,"n":242,"w":0.073,"pr":0.234},{"p":65,"n":170,"w":0.082,"pr":0.091},{"p":37,"n":49,"w":0.07,"pr":0.054},{"p":45,"n":42,"w":0.072,"pr":0.039},{"p":32,"n":39,"w":0.06,"pr":0.06}],"pv":[29,16,65,75,25,15]},{"c":"W","p":6,"bw":0.076,"n":830,"top":[{"p":6,"n":99,"w":0.937,"pr":0.061},{"p":64,"n":72,"w":0.94,"pr":0.029},{"p":25,"n":67,"w":0.937,"pr":0.036},{"p":37,"n":58,"w":0.935,"pr":0.039},{"p":16,"n":46,"w":0.931,"pr":0.037}],"pv":[6,29,16,25,7,65]},{"c":"B","p":75,"bw":0.064,"n":899,"top":[{"p":29,"n":157,"w":0.071,"pr":0.118},{"p":65,"n":118,"w":0.077,"pr":0.063},{"p":16,"n":106,"w":0.07,"pr":0.086},{"p":7,"n":58,"w":0.058,"pr":0.08},{"p":37,"n":41,"w":0.069,"pr":0.034}],"pv":[29,16,25,32,24,26]},{"c":"W","p":74,"bw":0.069,"n":826,"top":[{"p":65,"n":111,"w":0.946,"pr":0.075},{"p":16,"n":107,"w":0.941,"pr":0.097},{"p":74,"n":104,"w":0.944,"pr":0.084},{"p":32,"n":93,"w":0.944,"pr":0.05},{"p":64,"n":56,"w":0.939,"pr":0.033}],"pv":[65,29,16,25,7,64]},{"c":"B","p":76,"bw":0.059,"n":904,"top":[{"p":65,"n":232,"w":0.065,"pr":0.181},{"p":76,"n":188,"w":0.068,"pr":0.122},{"p":29,"n":70,"w":0.059,"pr":0.074},{"p":73,"n":46,"w":0.061,"pr":0.045},{"p":16,"n":31,"w":0.052,"pr":0.043}],"pv":[65,76,73,75,29,54]},{"c":"W","p":65,"bw":0.063,"n":988,"top":[{"p":65,"n":203,"w":0.954,"pr":0.092},{"p":64,"n":120,"w":0.955,"pr":0.045},{"p":16,"n":119,"w":0.946,"pr":0.072},{"p":32,"n":78,"w":0.94,"pr":0.058},{"p":54,"n":73,"w":0.949,"pr":0.023}],"pv":[65,29,16,25,32,17]},{"c":"B","p":29,"bw":0.048,"n":1003,"top":[{"p":29,"n":125,"w":0.052,"pr":0.101},{"p":16,"n":99,"w":0.056,"pr":0.064},{"p":7,"n":90,"w":0.055,"pr":0.064},{"p":64,"n":86,"w":0.053,"pr":0.068},{"p":44,"n":60,"w":0.054,"pr":0.042}],"pv":[29,16,25,32,17,7]},{"c":"W","p":16,"bw":0.053,"n":925,"top":[{"p":16,"n":190,"w":0.963,"pr":0.114},{"p":32,"n":68,"w":0.95,"pr":0.059},{"p":7,"n":53,"w":0.947,"pr":0.053},{"p":25,"n":53,"w":0.945,"pr":0.056},{"p":44,"n":51,"w":0.956,"pr":0.029}],"pv":[16,25,32,17,7,24]},{"c":"B","p":37,"bw":0.037,"n":990,"top":[{"p":25,"n":457,"w":0.042,"pr":0.336},{"p":7,"n":58,"w":0.038,"pr":0.054},{"p":35,"n":47,"w":0.037,"pr":0.046},{"p":64,"n":40,"w":0.036,"pr":0.042},{"p":17,"n":35,"w":0.042,"pr":0.027}],"pv":[25,32,17,7,24,35]},{"c":"W","p":32,"bw":0.037,"n":818,"top":[{"p":25,"n":105,"w":0.972,"pr":0.111},{"p":32,"n":101,"w":0.979,"pr":0.069},{"p":64,"n":59,"w":0.97,"pr":0.046},{"p":35,"n":47,"w":0.959,"pr":0.061},{"p":45,"n":40,"w":0.967,"pr":0.038}],"pv":[25,35,32,24,26]},{"c":"B","p":45,"bw":0.024,"n":901,"top":[{"p":45,"n":129,"w":0.034,"pr":0.08},{"p":64,"n":123,"w":0.028,"pr":0.112},{"p":24,"n":80,"w":0.026,"pr":0.081},{"p":35,"n":47,"w":0.026,"pr":0.046},{"p":25,"n":43,"w":0.022,"pr":0.052}],"pv":[45,64,54,63,36,24]},{"c":"W","p":64,"bw":0.042,"n":929,"top":[{"p":64,"n":235,"w":0.978,"pr":0.154},{"p":63,"n":95,"w":0.971,"pr":0.056},{"p":36,"n":92,"w":0.972,"pr":0.049},{"p":38,"n":90,"w":0.974,"pr":0.034},{"p":73,"n":36,"w":0.962,"pr":0.032}],"pv":[64,54,63,36,25,38]},{"c":"B","p":54,"bw":0.025,"n":1035,"top":[{"p":54,"n":476,"w":0.034,"pr":0.273},{"p":24,"n":100,"w":0.024,"pr":0.102},{"p":25,"n":40,"w":0.018,"pr":0.051},{"p":35,"n":33,"w":0.021,"pr":0.038},{"p":63,"n":30,"w":0.018,"pr":0.039}],"pv":[54,63,36,25,38,48]},{"c":"W","p":63,"bw":0.034,"n":1276,"top":[{"p":63,"n":324,"w":0.976,"pr":0.167},{"p":36,"n":235,"w":0.979,"pr":0.108},{"p":38,"n":93,"w":0.974,"pr":0.037},{"p":53,"n":59,"w":0.97,"pr":0.032},{"p":62,"n":52,"w":0.97,"pr":0.029}],"pv":[63,36,24,38,48,44]},{"c":"B","p":36,"bw":0.027,"n":1124,"top":[{"p":36,"n":433,"w":0.036,"pr":0.201},{"p":24,"n":103,"w":0.027,"pr":0.088},{"p":25,"n":58,"w":0.023,"pr":0.06},{"p":44,"n":45,"w":0.024,"pr":0.045},{"p":35,"n":44,"w":0.029,"pr":0.036}],"pv":[36,25,38,48,35,44]},{"c":"W","p":25,"bw":0.035,"n":1233,"top":[{"p":62,"n":129,"w":0.971,"pr":0.075},{"p":53,"n":121,"w":0.973,"pr":0.063},{"p":44,"n":118,"w":0.974,"pr":0.059},{"p":25,"n":108,"w":0.963,"pr":0.084},{"p":38,"n":99,"w":0.974,"pr":0.045}],"pv":[62,24,25,35,44,71]},{"c":"B","p":38,"bw":0.039,"n":908,"top":[{"p":38,"n":301,"w":0.054,"pr":0.107},{"p":35,"n":65,"w":0.033,"pr":0.089},{"p":44,"n":48,"w":0.037,"pr":0.057},{"p":3,"n":40,"w":0.03,"pr":0.059},{"p":53,"n":37,"w":0.032,"pr":0.051}],"pv":[38,48,73,44,3,24]},{"c":"W","p":48,"bw":0.054,"n":1101,"top":[{"p":48,"n":184,"w":0.921,"pr":0.37},{"p":53,"n":109,"w":0.966,"pr":0.035},{"p":3,"n":99,"w":0.963,"pr":0.036},{"p":24,"n":97,"w":0.955,"pr":0.041},{"p":44,"n":96,"w":0.961,"pr":0.032}],"pv":[48,72,62,71,53,35]},{"c":"B","p":53,"bw":0.093,"n":984,"top":[{"p":72,"n":661,"w":0.114,"pr":0.031},{"p":73,"n":104,"w":0.102,"pr":0.052},{"p":44,"n":21,"w":0.037,"pr":0.075},{"p":35,"n":17,"w":0.028,"pr":0.066},{"p":53,"n":16,"w":0.031,"pr":0.063}],"pv":[72,62,71,53,3,35]},{"c":"W","p":44,"bw":0.066,"n":816,"top":[{"p":44,"n":102,"w":0.941,"pr":0.102},{"p":62,"n":92,"w":0.955,"pr":0.039},{"p":3,"n":65,"w":0.94,"pr":0.054},{"p":10,"n":54,"w":0.941,"pr":0.044},{"p":26,"n":51,"w":0.95,"pr":0.046}],"pv":[44,72,62,71,53,3]},{"c":"B","p":62,"bw":0.084,"n":902,"top":[{"p":72,"n":545,"w":0.106,"pr":0.029},{"p":73,"n":121,"w":0.097,"pr":0.047},{"p":62,"n":55,"w":0.048,"pr":0.156},{"p":35,"n":23,"w":0.026,"pr":0.09},{"p":3,"n":14,"w":0.02,"pr":0.058}],"pv":[72,62,71,53,2,70]},{"c":"W","p":3,"bw":0.063,"n":855,"top":[{"p":3,"n":110,"w":0.948,"pr":0.069},{"p":10,"n":102,"w":0.96,"pr":0.051},{"p":78,"n":75,"w":0.959,"pr":0.053},{"p":17,"n":65,"w":0.948,"pr":0.046},{"p":79,"n":55,"w":0.948,"pr":0.035}],"pv":[3,73,2,1,10,9]},{"c":"B","p":10,"bw":0.069,"n":910,"top":[{"p":72,"n":426,"w":0.09,"pr":0.037},{"p":73,"n":209,"w":0.084,"pr":0.059},{"p":2,"n":46,"w":0.047,"pr":0.095},{"p":35,"n":32,"w":0.031,"pr":0.09},{"p":26,"n":16,"w":0.02,"pr":0.054}],"pv":[72,2,9,1,10,0]},{"c":"W","p":2,"bw":0.056,"n":813,"top":[{"p":78,"n":131,"w":0.97,"pr":0.055},{"p":79,"n":77,"w":0.958,"pr":0.042},{"p":2,"n":77,"w":0.95,"pr":0.063},{"p":1,"n":55,"w":0.952,"pr":0.037},{"p":35,"n":41,"w":0.922,"pr":0.096}],"pv":[78,68,70,79,2,71]},{"c":"B","p":1,"bw":0.075,"n":877,"top":[{"p":72,"n":557,"w":0.096,"pr":0.05},{"p":73,"n":70,"w":0.081,"pr":0.052},{"p":1,"n":39,"w":0.052,"pr":0.098},{"p":24,"n":24,"w":0.036,"pr":0.075},{"p":35,"n":21,"w":0.024,"pr":0.076}],"pv":[72,1,35,9,18,0]},{"c":"W","p":9,"bw":0.053,"n":839,"top":[{"p":78,"n":101,"w":0.963,"pr":0.08},{"p":35,"n":72,"w":0.942,"pr":0.096},{"p":7,"n":64,"w":0.955,"pr":0.06},{"p":27,"n":62,"w":0.951,"pr":0.049},{"p":79,"n":58,"w":0.952,"pr":0.045}],"pv":[78,68,70,79,26,71]},{"c":"B","p":18,"bw":0.097,"n":842,"top":[{"p":72,"n":460,"w":0.119,"pr":0.049},{"p":7,"n":119,"w":0.108,"pr":0.073},{"p":73,"n":37,"w":0.085,"pr":0.067},{"p":35,"n":32,"w":0.06,"pr":0.096},{"p":24,"n":23,"w":0.058,"pr":0.07}],"pv":[72,18,24,35,0,73]},{"c":"W","p":78,"bw":0.062,"n":816,"top":[{"p":78,"n":110,"w":0.957,"pr":0.084},{"p":24,"n":94,"w":0.943,"pr":0.116},{"p":71,"n":78,"w":0.945,"pr":0.064},{"p":35,"n":75,"w":0.929,"pr":0.111},{"p":7,"n":74,"w":0.94,"pr":0.08}],"pv":[78,68,70,79,24,73]},{"c":"B","p":79,"bw":0.058,"n":910,"top":[{"p":68,"n":542,"w":0.067,"pr":0.361},{"p":79,"n":117,"w":0.052,"pr":0.161},{"p":73,"n":75,"w":0.068,"pr":0.043},{"p":26,"n":32,"w":0.051,"pr":0.046},{"p":35,"n":26,"w":0.038,"pr":0.053}],"pv":[68,70,79,35,72,26]},{"c":"W","p":24,"bw":0.06,"n":917,"top":[{"p":24,"n":120,"w":0.947,"pr":0.106},{"p":7,"n":112,"w":0.945,"pr":0.101},{"p":26,"n":112,"w":0.943,"pr":0.111},{"p":8,"n":105,"w":0.945,"pr":0.088},{"p":35,"n":103,"w":0.943,"pr":0.106}],"pv":[24,72,73,0,35,71]},{"c":"B","p":-1,"bw":0.069,"n":920,"top":[{"p":72,"n":297,"w":0.086,"pr":0.084},{"p":73,"n":90,"w":0.069,"pr":0.097},{"p":35,"n":76,"w":0.066,"pr":0.092},{"p":23,"n":53,"w":0.063,"pr":0.071},{"p":7,"n":48,"w":0.063,"pr":0.066}],"pv":[72,73,0,35,71,15]},{"c":"W","p":-1,"bw":0.022,"n":800,"top":[{"p":-1,"n":409,"w":1,"pr":0.032},{"p":35,"n":45,"w":0.968,"pr":0.117},{"p":26,"n":45,"w":0.965,"pr":0.1},{"p":70,"n":45,"w":0.96,"pr":0.103},{"p":14,"n":44,"w":0.953,"pr":0.107}],"pv":[-1]}]} \ No newline at end of file diff --git a/web/data/sessions.json b/web/data/sessions.json new file mode 100644 index 0000000..7fc3cee --- /dev/null +++ b/web/data/sessions.json @@ -0,0 +1 @@ +{"play":{"title":"bin/gofer play -model models/gofer-9x9.onnx","lines":[{"t":"cmd","s":"bin/gofer play -model models/gofer-9x9.onnx"},{"t":"out","s":"Moves: D4, pass, resign, hint, quit"},{"t":"in","s":"E5"},{"t":"gold","s":"Gofer plays D4 (win 54%, pv D4 D6 F4 E4 E3)"},{"t":"dim","s":" A B C D E F G H J"},{"t":"dim","s":" 9 . . . . . . . . . 9"},{"t":"dim","s":" 8 . . . . . . . . . 8"},{"t":"dim","s":" 7 . . . . . . . . . 7"},{"t":"dim","s":" 6 . . . . . . . . . 6"},{"t":"dim","s":" 5 . . . . X . . . . 5"},{"t":"dim","s":" 4 . . . O). . . . . 4"},{"t":"dim","s":" 3 . . . . . . . . . 3"},{"t":"dim","s":" 2 . . . . . . . . . 2"},{"t":"dim","s":" 1 . . . . . . . . . 1"},{"t":"in","s":"hint"},{"t":"out","s":"hint: D6 (win 45%)"},{"t":"in","s":"C6"},{"t":"dim","s":" A B C D E F G H J"},{"t":"dim","s":" 9 . . . . . . . . . 9"},{"t":"dim","s":" 8 . . . . . . . . . 8"},{"t":"dim","s":" 7 . . . . . . . . . 7"},{"t":"dim","s":" 6 . . X). . . . . . 6"},{"t":"dim","s":" 5 . . . . X . . . . 5"},{"t":"dim","s":" 4 . . . O . . . . . 4"},{"t":"dim","s":" 3 . . . . . . . . . 3"},{"t":"dim","s":" 2 . . . . . . . . . 2"},{"t":"dim","s":" 1 . . . . . . . . . 1"},{"t":"gold","s":"Gofer plays F4 (win 54%, pv F4 F5 E3 G4 G3 H4)"}]},"gtp":{"title":"bin/gofer gtp -model models/gofer-9x9.onnx","lines":[{"t":"cmd","s":"bin/gofer gtp -model models/gofer-9x9.onnx"},{"t":"in","s":"boardsize 9"},{"t":"out","s":"= "},{"t":"in","s":"clear_board"},{"t":"out","s":"= "},{"t":"in","s":"komi 7.5"},{"t":"out","s":"= "},{"t":"in","s":"play B E5"},{"t":"out","s":"= "},{"t":"in","s":"genmove W"},{"t":"gold","s":"= D4"},{"t":"in","s":"play B C4"},{"t":"out","s":"= "},{"t":"in","s":"gofer-analyze 800"},{"t":"gold","s":"= value +0.125 winrate 56.2% visits 800"},{"t":"gold","s":"D5 visits 459 winrate 58.4% prior 13.4%"},{"t":"out","s":"C3 visits 62 winrate 55.4% prior 8.5%"},{"t":"out","s":"C5 visits 58 winrate 54.1% prior 11.3%"},{"t":"out","s":"E3 visits 24 winrate 53.1% prior 6.4%"},{"t":"out","s":"E4 visits 20 winrate 55.4% prior 2.9%"},{"t":"out","s":"F4 visits 17 winrate 52.1% prior 5.0%"},{"t":"out","s":"D3 visits 14 winrate 52.5% prior 3.8%"},{"t":"out","s":"F5 visits 8 winrate 54.6% prior 1.4%"},{"t":"out","s":"pv D5 D6 C5 E4 E3 D3 C3 D2"}]},"selfplay":{"title":"bin/gofer selfplay","lines":[{"t":"cmd","s":"bin/gofer selfplay -model runs/9x9/models/gen-0016.onnx -games 600 \\"},{"t":"cmd2","s":" -full-visits 160 -fast-visits 32 -full-prob 0.25 -out runs/9x9/data/gen-0016"},{"t":"out","s":"selfplay: 50/600 games, 1.0 games/s, 841 rows, black wins 38.0%"},{"t":"out","s":"selfplay: 150/600 games, 1.2 games/s, 2717 rows, black wins 44.0%"},{"t":"out","s":"selfplay: 250/600 games, 1.2 games/s, 4527 rows, black wins 46.8%"},{"t":"out","s":"selfplay: 350/600 games, 1.2 games/s, 6336 rows, black wins 44.9%"},{"t":"out","s":"selfplay: 450/600 games, 1.2 games/s, 8168 rows, black wins 45.3%"},{"t":"dim","s":"…"},{"t":"gold","s":"selfplay: 600 games, 10992 rows in 470s (1.28 games/s), black won 44.5%, resigned 440, false resigns 1/53"},{"t":"com","s":"# 25% of moves get a full 160-visit search and become training rows;"},{"t":"com","s":"# the rest get 32 visits and are only played (playout-cap randomization)."}]},"loop":{"title":"bin/gofer arena","lines":[{"t":"cmd","s":"bin/gofer arena -a nn:models/gofer-9x9.onnx,visits=400 -b gnugo,level=10 -games 100"},{"t":"gold","s":"gen-0017@400 vs gnugo-l10: 66-34-0 score 0.660 [0.563, 0.745] elo +115 [+44, +187]"},{"t":"cmd","s":"bin/gofer arena -a nn:models/gofer-9x9.onnx,visits=400 -b gnugo,level=1 -games 100"},{"t":"gold","s":"gen-0017@400 vs gnugo-l1: 78-22-0 score 0.780 [0.689, 0.850] elo +220 [+138, +301]"},{"t":"com","s":"# paired random openings, colors swapped, GNU Go judges dead stones."},{"t":"cmd","s":"grep eval: runs/9x9/log.txt | tail -4"},{"t":"out","s":"eval: gen-0016 vs gen-0015: 25-15-0 elo +89 [-21, +198]"},{"t":"out","s":"eval: gen-0016 vs rollout: 40-0-0 score 1.00"},{"t":"out","s":"eval: gen-0016 vs gnugo-l1: 26-14-0 score 0.65"},{"t":"out","s":"eval: gen-0016 vs gnugo-l6: 23-17-0 score 0.57"}]}} \ No newline at end of file diff --git a/web/engine.js b/web/engine.js new file mode 100644 index 0000000..79be984 --- /dev/null +++ b/web/engine.js @@ -0,0 +1,66 @@ +/* Gofer in the browser: the Go engine (rules + search, compiled to WebAssembly) + evaluating positions with the real network through onnxruntime-web. + Everything is fetched on first use: loadGofer(onStatus) -> Promise. + + gofer.newGame(komi) -> state + gofer.play(point) -> {ok, error?, state} point = y*9+x, -1 = pass + gofer.undo(n) -> state + gofer.genmove(visits, onProgress?) -> Promise<{move, value, visits, top, pv, state}> + gofer.state() -> {cells, toPlay, last, legal, gameOver, margin, capturesB, capturesW, ...} +*/ +(function () { + 'use strict'; + var ORT_VERSION = '1.26.0'; + var ORT_BASE = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@' + ORT_VERSION + '/dist/'; + var loading = null; + + function loadScript(src) { + return new Promise(function (resolve, reject) { + var s = document.createElement('script'); + s.src = src; + s.onload = resolve; + s.onerror = function () { reject(new Error('could not load ' + src)); }; + document.head.appendChild(s); + }); + } + + async function load(status) { + status('Loading the neural-network runtime…'); + if (!window.ort) await loadScript(ORT_BASE + 'ort.wasm.min.js'); + ort.env.wasm.wasmPaths = ORT_BASE; + ort.env.wasm.numThreads = self.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency || 1) : 1; + + status('Loading the network (1.9 MB)…'); + var session = await ort.InferenceSession.create('engine/gofer-9x9.onnx', { executionProviders: ['wasm'] }); + + status('Starting the engine…'); + if (!window.Go) await loadScript('engine/wasm_exec.js'); + var go = new Go(); + var ready = new Promise(function (r) { window.goferReady = r; }); + var bytes = await fetch('engine/gofer.wasm').then(function (r) { + if (!r.ok) throw new Error('engine/gofer.wasm: HTTP ' + r.status); + return r.arrayBuffer(); + }); + var inst = await WebAssembly.instantiate(bytes, go.importObject); + go.run(inst.instance); + await ready; + + window.gofer.setEvaluator(async function (spatial, global, n) { + var out = await session.run({ + spatial: new ort.Tensor('float32', spatial, [n, 13, 9, 9]), + global: new ort.Tensor('float32', global, [n, 4]), + }); + return { policy: out.policy.data, value: out.value.data }; + }); + status(''); + return window.gofer; + } + + window.loadGofer = function (onStatus) { + if (!loading) { + loading = load(onStatus || function () {}); + loading.catch(function () { loading = null; }); + } + return loading; + }; +})(); diff --git a/web/engine/gofer-9x9.onnx b/web/engine/gofer-9x9.onnx new file mode 100644 index 0000000..24a7c04 Binary files /dev/null and b/web/engine/gofer-9x9.onnx differ diff --git a/web/engine/gofer.wasm b/web/engine/gofer.wasm new file mode 100755 index 0000000..1da73e9 Binary files /dev/null and b/web/engine/gofer.wasm differ diff --git a/web/engine/wasm_exec.js b/web/engine/wasm_exec.js new file mode 100644 index 0000000..d71af9e --- /dev/null +++ b/web/engine/wasm_exec.js @@ -0,0 +1,575 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +"use strict"; + +(() => { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!globalThis.path) { + globalThis.path = { + resolve(...pathSegments) { + return pathSegments.join("/"); + } + } + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + } + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const testCallExport = (a, b) => { + this._inst.exports.testExport0(); + return this._inst.exports.testExport(a, b); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + callExport: testCallExport, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8), + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } +})(); diff --git a/web/favicon-32.png b/web/favicon-32.png index 762a882..b34989c 100644 Binary files a/web/favicon-32.png and b/web/favicon-32.png differ diff --git a/web/favicon.svg b/web/favicon.svg index 45b147b..a36091a 100644 --- a/web/favicon.svg +++ b/web/favicon.svg @@ -1,16 +1,11 @@ - - - - - + - - - - - - - + + + + + + diff --git a/web/fonts/LICENSE.txt b/web/fonts/LICENSE.txt new file mode 100644 index 0000000..5aabcd4 --- /dev/null +++ b/web/fonts/LICENSE.txt @@ -0,0 +1,297 @@ +Fonts in this directory are redistributed under the SIL Open Font License 1.1. +Files were taken from the Fontsource npm packages (latin subsets). + +bricolage-grotesque.woff2 Bricolage Grotesque (variable: opsz, wght) @fontsource-variable/bricolage-grotesque +instrument-serif-italic.woff2 Instrument Serif Italic @fontsource/instrument-serif +jetbrains-mono.woff2 JetBrains Mono (variable: wght) @fontsource-variable/jetbrains-mono + +============================================================================== +@fontsource-variable/bricolage-grotesque +============================================================================== +Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +============================================================================== +@fontsource/instrument-serif +============================================================================== +Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif) InstrumentSerif-Italic.ttf: Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +============================================================================== +@fontsource-variable/jetbrains-mono +============================================================================== +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/fonts/bricolage-grotesque.woff2 b/web/fonts/bricolage-grotesque.woff2 new file mode 100644 index 0000000..fcc4eb1 Binary files /dev/null and b/web/fonts/bricolage-grotesque.woff2 differ diff --git a/web/fonts/instrument-serif-italic.woff2 b/web/fonts/instrument-serif-italic.woff2 new file mode 100644 index 0000000..197abca Binary files /dev/null and b/web/fonts/instrument-serif-italic.woff2 differ diff --git a/web/fonts/jetbrains-mono.woff2 b/web/fonts/jetbrains-mono.woff2 new file mode 100644 index 0000000..cd5102a Binary files /dev/null and b/web/fonts/jetbrains-mono.woff2 differ diff --git a/web/icon-512.png b/web/icon-512.png index 7d1e623..0c85f0d 100644 Binary files a/web/icon-512.png and b/web/icon-512.png differ diff --git a/web/index.html b/web/index.html index 0c9418a..7826424 100644 --- a/web/index.html +++ b/web/index.html @@ -1,1496 +1,384 @@ - - + + -Gofer — a Go engine that teaches itself - +Gofer — a Go engine that taught itself + - - + + + - + + - - - - + + + - + + + + - - - - - - - - - - -
- -
- -
- - - - -
-
-

MCTS  ·  GTP  ·  ONNX  ·  self-play

-

- Four thousand years of Go. Four thousand lines of Go. -

-

- Gofer is an AlphaZero-style Go engine — a bitset board, PUCT search, batched ONNX - inference and GTP, in Go — plus a PyTorch loop that taught it 9×9 Go from random play. - 3.6 hours on four CPU cores later, it beats GNU Go 66–34. - Every number on this page is measured: Wilson intervals, an Elo ladder, GNU Go as the yardstick. -

-
- - Quickstart → +
+ + +
+
+
+

Self-play reinforcement learning · 9×9

+

Taught itselfGo. In Go.

+

Gofer started out knowing only the rules. It played 10,200 games against itself, learned from every one, and 3.6 hours later beat GNU Go 66–34. No human games. No GPU. Four CPU cores and one loop.

+ -

scripts/setup.sh → bin/gofer  ·  rules, search, GTP and arena build in pure Go

+
    +
  • Go engine
  • PyTorch trainer
  • ONNX Runtime
  • WebAssembly
  • +
- -
- - -
-
-
- - -
-
1846 · Shusaku vs Gennan Inseki
-
- -
-
- - -
- - -
-
- - - - - - - 0 / 325 -
-
- - - - -
-   - captures — B 0 · W 0 -
+
+
+
GNU Go 3.8level 10 · Black
+ vs +
Gofer400 visits · White
+
+
+ +
+ +
-

- The “ear-reddening” game — Honinbo Shusaku vs Inoue Gennan Inseki, 1846. Gofer itself never saw a human game. -

-
-
-
- - -
-
-
-
66–34
-
against GNU Go 3.8, level 10
-
100 games · 400 visits · Elo +115 [+44, +187]
-
-
-
3.6h
-
from random play, on 4 vCPUs
-
10,200 self-play games · no GPU · no human data
-
-
-
20–0
-
against the v2 engine
-
at a quarter of its search budget
-
-
-
490k
-
playouts per second of pure search
-
4 vCPUs · ~4 µs per playout · was ~520 µs
-
+ +
A real game from the evaluation: 100 games like this one, colors swapped, GNU Go judging dead stones.
+
- -
-

Inside the engine

-

Everything a Go engine needs. Nothing it doesn't.

-

One binary plays, analyzes, referees and generates training data; Python only trains. The design is written down in docs/architecture.md.

- -
-
- -

Rules that hold up

-

Chains and exact liberties kept incrementally in bitsets, simple ko, positional superko from per-chain Zobrist hashes, Tromp-Taylor area scoring. Differential-tested against a naive reference on random games from 5×5 to 19×19.

-

internal/game · 0.7 µs per move incl. legal moves

-
-
- -

PUCT search, done right

-

One documented sign convention, virtual-loss leaf batching, Dirichlet root noise, tree reuse, exact scoring of finished games, and a pass rule that keeps an untrained net from ending games at random.

-

internal/mcts · regression test for every v2 defect

+ +
+
+
+

66–34

+

against GNU Go 3.8 at its default level

+

100 games · 400 visits a move · Elo +115 [+44, +187]

-
- -

Batched inference

-

Concurrent searches feed one evaluator; worker sessions run ONNX Runtime on every CPU core, or CUDA / TensorRT on a GPU. Random board symmetry per evaluation and a position cache.

-

-device cpu|cuda|tensorrt · 6×64 net · 477k params

+
+

3.6hours

+

from random play to that result

+

17 generations · one 4-vCPU VM · no GPU

-
- -

Speaks GTP

-

Drop it into Sabaki or Lizzie. Live lz-analyze overlays, time_settings/time_left budgets, undo, final_score, resignation.

-

bin/gofer gtp -model models/gofer-9x9.onnx

+
+

10,200

+

games it played against itself

+

197k training positions, each seen in 8 symmetries

-
- -

A data factory

-

Self-play with KataGo's playout-cap randomization writes compact NumPy shards: 13 feature planes, visit-count policy, game result, search value, final ownership. Resignation with measured false-positive rate.

-

gofer selfplay → .npz · ~1.2 games/s on 4 vCPUs

-
-
- -

An honest arena

-

Any two players — networks, rollout MCTS, GNU Go or any GTP engine. Paired openings with colors swapped, GNU Go dead-stone adjudication, Wilson intervals, Elo, and SPRT for gating.

-

gofer arena -a nn:model.onnx -b gnugo,level=10

+
+

0

+

human games it ever saw

+

the rules, then self-play: nothing else

- -
-

The training loop

-

Random play to GNU Go, in an afternoon.

-

Every generation: the newest network plays itself, a replay window of recent games trains the next one with all eight board symmetries, the export is checked against PyTorch, and the result is rated against its predecessor and against GNU Go. Stop it anywhere — the same command resumes.

- - - - - -
-
Self-play
newest net vs itself → .npz
-
Replay window
grows 60k → 400k rows
-
Train
PyTorch · 8 symmetries · AdamW
-
Export ONNX
6×64 resnet · 1.9 MB · verified
-
Evaluate
vs previous gen + GNU Go
-
Elo ladder — and the loop restarts
Bradley–Terry fit, gen 0 = 0
-
- -
-
-

No gate

-

Like AlphaZero and KataGo, the newest network always plays the next games. Evaluation is measurement, not a toll booth — v2 spent five times more compute gating than generating data. SPRT is there if you want a gate.

-
-
-

The yardstick

-

Strength is measured against an engine nobody tuned for: GNU Go 3.8 — 66–34 at its default level 10, 78–22 at level 1, over 100 games each with colors swapped. GNU Go judges dead stones at the end of every game.

-
-
-

The honest part

-

v2's search backed values up with the wrong sign and skipped the search on fresh trees, so its training never had a chance. The audit lists every defect, with measurements and a regression test.

+ +
+
+
+

01How it learns

+

The whole trick is one loop.

+

Play, learn, check, repeat. Each lap produces a new network, and that network plays the next lap's games, so the teacher and the student improve together. Gofer ran the loop 17 times, about 13 minutes a lap.

+
+ +
    +
  1. +
    1Go
    internal/selfplay
    +

    Play itself

    +

    The newest network plays 600 games against itself. Every move comes from a tree search that the network guides, and the search's choice, not the network's first guess, becomes the lesson.

    +
    +
    +

    600 games a lap1.3 games/s

    +
    +
  2. +
  3. +
    2PyTorch
    training/
    +

    Learn from it

    +

    A small residual network learns to predict two things from each position: where the search wanted to play, and who went on to win. Every position is used in all eight rotations and reflections.

    +
    +
    +

    policy loss 4.41 → 2.39lower is better

    +
    +
  4. +
  5. +
    3Go
    internal/arena
    +

    Prove it

    +

    The new network plays the previous one and GNU Go, colors swapped, with GNU Go judging dead stones. Every result goes onto an Elo ladder. Then the next lap starts.

    +
    +
    +

    gen 16's report card● win ○ loss

    +
    +
  6. +
+
- -
-
-
-

One binary

-

Meet it in the terminal.

-

Play it, pipe GTP at it, farm training data out of it, or referee a match against GNU Go. Every session below is real, captured output from the generation-17 network.

-
- - - - + +
+
+
+

02Watch it learn

+

From noise to Go, one generation at a time.

+

Drag through all 18 networks. On the left is a real game each one played against itself, or the moves it wants to open with before any search. On the right is how strong it was.

+
+ +
+
+
+ + +
+
+
-
-
-
-
- - - - bin/gofer play -model models/gofer-9x9.onnx +
+
+
Generation17
+
Elo+2318± 54 · gen 0 = 0
+
+

+
    +
    +
    + + +
    -
    
           
    -

    captured from bin/gofer v3 — not a mock-up

    -
    -
    - -
    -
    -
    -

    Where the time goes

    -

    The network is the only bottleneck.

    -

    v2 spent about as long in Go code as in the network for every evaluation, and ran the network on one core. v3 keeps chains and liberties incrementally, so a whole search step costs ~4 µs, and spreads inference over every core — or a GPU.

    -

    Measured on a 4-vCPU cloud VM with go test -bench and gofer bench.

    -
    -
    -
    - - - - - - - - - - - - - - - - -
    benchmarkv3v2
    Random 9×9 game (legal moves every ply)134 µs—
    Board copy86 ns130 ns
    Go-side cost per network eval~4 µs~520 µs
    Pure search, 4 vCPUs490k/s—
    Network evals, 6×64, 4 vCPUs6,000/s1,130/s
    Cores used by self-play4 of 41.2 of 4
    -
    -

    v2 network figure is its own (larger) 6×64 net; see docs/v2-audit.md

    +
    +

    The final position of one self-play game from each generation. Tap one to open it.

    +

    gen 0: scattered stones · gen 17: walls and territory

    +
    - -
    -

    Paper trail

    -

    Every defect, written down.

    -

    v3 started with an audit of v2. The findings, the design that replaced it, and the run that proved it are all in the repo — including v2's original decision records.

    - - -
    - - -
    -

    Quickstart

    -

    Playing in five minutes.

    - -
    -
      -
    1. - 1 -
      -
      -
      $ git clone https://github.com/DevomB/Gofer.git && cd Gofer
      - + +
      + +
      +
      +

      03Play

      +

      Your move.

      +

      This is the real engine, not a recording: the same Go search and the same network as bin/gofer, compiled to WebAssembly and running on your device. Nothing is sent to a server.

      +
      + +
      +
      +
      + +
      +

      You play Black · Club strength

      -
    2. -
    3. - 2 -
      -
      -
      $ scripts/setup.sh && make test
      - +
      + +
    4. -
    5. - 3 -
      -
      -
      $ bin/gofer play -model models/gofer-9x9.onnx
      - +
      +

      Gofer's strength

      +
      + + + +
      -

      you're Black — type hint if you get stuck

      + +

      First start downloads the engine and network (about 5 MB).

      -
    6. -
    7. - 4 -
      -
      -
      $ bin/gofer gtp -model models/gofer-9x9.onnx
      - + + -
    8. -
    9. - 5 -
      -
      -
      $ .venv/bin/python -m training.loop --run runs/9x9 --gofer bin/gofer
      - +
      + + +
      1. Gofer's candidate moves appear here while it thinks.
      +
      +
      Move
      0
      +
      Captures
      0 · 0
      +
      Komi
      7.5
      +
      + +
      + + +
      -

      the whole loop, resumable: self-play → train → export → evaluate

      -
    10. -
    + +
    +
    + Rules area scoring, komi 7.5, positional superko + Tip capture dead stones before you pass + Strength “Club” is the setting that beat GNU Go 66–34 +
    +
    +
    -
    -

    What you need

    -
      -
    • ●Go 1.22+ and a C compiler for the engine
    • -
    • ○ONNX Runtime — fetched by scripts/install-ort.sh (CPU or GPU)
    • -
    • ○Python + PyTorch — only to train networks
    • -
    -
    -

    Default visits per move:

    -
    -
    play
    400
    -
    gtp
    800
    -
    arena
    200
    + +
    +
    +
    +

    04Under the hood

    +

    Built like a real engine.

    +

    About 5,000 lines of Go and 1,000 of Python, with tests at every layer and a CI job that runs a whole lap of the loop against GNU Go.

    +
    + +
    +
    +
    Board
    Bitset board with exact liberties and positional superko from per-chain Zobrist hashes, differential-tested against a naive reference from 5×5 to 19×19.
    134 µs per random 9×9 game
    +
    Search
    PUCT Monte-Carlo tree search with virtual-loss leaf batching, tree reuse, Dirichlet root noise and playout-cap randomization.
    ≈4 µs per playout
    +
    Network
    Residual net, 6 blocks × 64 channels with global pooling; policy, value and ownership heads over 13 input planes.
    477k parameters · 1.9 MB
    +
    Inference
    ONNX Runtime called from Go, batched across worker sessions with a position cache. CPU, CUDA or TensorRT.
    6,400 playouts/s on 4 vCPUs
    +
    Training
    PyTorch with AdamW, eight-fold symmetry augmentation and a replay window that grows from 60k to 400k positions. Stop it any time; the same command resumes.
    +
    Measuring
    Bradley–Terry Elo, Wilson intervals and SPRT, with GNU Go as the outside yardstick.
    +
    Protocol
    GTP v2 with live lz-analyze, so it plays inside Sabaki, Lizzie and GoGui.
    +
    Browser
    This page: the search compiled to WebAssembly, the network in onnxruntime-web.
    +
    + +
    +
    +
    + +
    + + + + +
    +
    +

    captured sessions, replayed

    +
    
    +        
    +
    +
    +

    Run it yourself

    + +
    +
    +
    git clone https://github.com/DevomB/Gofer && cd Gofer
    +scripts/setup.sh   # ONNX Runtime, GNU Go, venv, bin/gofer
    +bin/gofer play -model models/gofer-9x9.onnx
    +
    -
    -
    -

    Docs worth reading: the architecture, the training guide, and the v2 audit.

    - -
    -
    -
    +
    + + - + + diff --git a/web/og-image.png b/web/og-image.png index 4cbb438..7445eee 100644 Binary files a/web/og-image.png and b/web/og-image.png differ diff --git a/web/site.css b/web/site.css new file mode 100644 index 0000000..65ba4d6 --- /dev/null +++ b/web/site.css @@ -0,0 +1,531 @@ +/* Gofer — paper, ink and a vermilion seal. + Hand-written; no build step. Fonts are self-hosted (OFL, see fonts/LICENSE.txt). */ + +@font-face { + font-family: "Bricolage"; + src: url(fonts/bricolage-grotesque.woff2) format("woff2-variations"), url(fonts/bricolage-grotesque.woff2) format("woff2"); + font-weight: 200 800; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Instrument Serif"; + src: url(fonts/instrument-serif-italic.woff2) format("woff2"); + font-weight: 400; + font-style: italic; + font-display: swap; +} +@font-face { + font-family: "JetBrains Mono"; + src: url(fonts/jetbrains-mono.woff2) format("woff2-variations"), url(fonts/jetbrains-mono.woff2) format("woff2"); + font-weight: 100 800; + font-style: normal; + font-display: swap; +} + +:root { + --paper: #f3eee4; + --paper-2: #ebe4d6; + --paper-3: #e1d8c6; + --ink: #16140f; + --ink-2: #3a362d; + --ink-3: #6b6454; + --rule: rgba(22, 20, 15, .14); + --rule-2: rgba(22, 20, 15, .08); + --red: #d63c1b; + --red-deep: #b5321a; + --red-soft: rgba(214, 60, 27, .12); + --wood: #e2b872; + --night: #14120e; + --night-2: #1d1a15; + --night-3: #27231c; + --night-ink: #f3eee4; + --night-ink-2: #c9c1b1; + --night-ink-3: #948b79; + --night-rule: rgba(243, 238, 228, .12); + + --sans: "Bricolage", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --serif: "Instrument Serif", ui-serif, Georgia, "Times New Roman", serif; + --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + --wrap: 1240px; + --gutter: clamp(16px, 4vw, 40px); + --ease: cubic-bezier(.2, .7, .2, 1); + --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .09 0 0 0 0 .08 0 0 0 0 .06 0 0 0 .07 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +*, *::before, *::after { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; scroll-behavior: smooth; scroll-padding-top: 76px; } +@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } +body { + margin: 0; + background: var(--paper) var(--grain); + color: var(--ink); + font: 400 1.0625rem/1.6 var(--sans); + font-optical-sizing: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; +} +img, svg, canvas { display: block; max-width: 100%; } +a { color: inherit; } +button { font: inherit; color: inherit; } +p { margin: 0; } +h1, h2, h3 { margin: 0; font-weight: 700; } +ol, ul { margin: 0; padding: 0; list-style: none; } +::selection { background: var(--red); color: #fff; } +:focus-visible { outline: 2px solid var(--red); outline-offset: 3px; border-radius: 4px; } +[hidden] { display: none !important; } + +.wrap { width: 100%; max-width: var(--wrap); margin: 0 auto; padding: 0 var(--gutter); } +.mono { font-family: var(--mono); } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +.skip { position: absolute; left: 12px; top: -60px; z-index: 100; background: var(--ink); color: var(--paper); padding: 10px 14px; border-radius: 8px; text-decoration: none; } +.skip:focus { top: 12px; } + +em, .serif { font-family: var(--serif); font-style: italic; font-weight: 400; letter-spacing: -.01em; } + +/* ---------- buttons ---------- */ +.btn { + --h: 44px; + display: inline-flex; align-items: center; justify-content: center; gap: .55em; + height: var(--h); padding: 0 calc(var(--h) * .45); + border-radius: 999px; border: 1.5px solid transparent; + font: 600 .95rem/1 var(--sans); letter-spacing: -.005em; + text-decoration: none; cursor: pointer; white-space: nowrap; + transition: transform .2s var(--ease), background-color .2s, border-color .2s, color .2s, box-shadow .2s; +} +.btn:active { transform: translateY(1px) scale(.99); } +.btn-lg { --h: 54px; font-size: 1.02rem; } +.btn-sm { --h: 36px; font-size: .88rem; } +.btn-red { background: var(--red); color: #fff; box-shadow: 0 1px 0 rgba(255,255,255,.25) inset, 0 10px 24px -12px rgba(214, 60, 27, .8); } +.btn-red:hover { background: var(--red-deep); transform: translateY(-1px); } +.btn-ink { background: var(--ink); color: var(--paper); } +.btn-ink:hover { background: #000; transform: translateY(-1px); } +.btn-line { border-color: var(--ink); color: var(--ink); background: transparent; } +.btn-line:hover { background: var(--ink); color: var(--paper); } +.btn-ghost { color: var(--ink-2); background: transparent; } +.btn-ghost:hover { color: var(--ink); background: var(--rule-2); } +.btn .arrow { transition: transform .25s var(--ease); } +.btn:hover .arrow { transform: translateX(3px); } +.btn[disabled] { opacity: .45; cursor: not-allowed; transform: none; } + +/* ---------- nav ---------- */ +.nav { + position: sticky; top: 0; z-index: 50; + background: color-mix(in srgb, var(--paper) 86%, transparent); + -webkit-backdrop-filter: saturate(1.4) blur(12px); backdrop-filter: saturate(1.4) blur(12px); + border-bottom: 1px solid transparent; + transition: border-color .3s; +} +.nav.scrolled { border-bottom-color: var(--rule); } +.nav-in { height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 24px; } +.brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; font-weight: 750; font-size: 1.4rem; letter-spacing: -.045em; } +.brand .mark { width: 28px; height: 28px; transition: transform .5s var(--ease); } +.brand:hover .mark { transform: rotate(-24deg); } +.nav-links { display: flex; gap: 4px; } +.nav-links a { text-decoration: none; color: var(--ink-2); font-size: .94rem; font-weight: 500; padding: 8px 12px; border-radius: 999px; transition: background-color .2s, color .2s; } +.nav-links a:hover { color: var(--ink); background: var(--rule-2); } +.nav-cta { display: flex; align-items: center; gap: 6px; } +.gh svg { width: 17px; height: 17px; } +@media (max-width: 860px) { .nav-links { display: none; } } +@media (max-width: 420px) { .gh span { display: none; } .gh { padding: 0 10px; } } + +/* ---------- hero ---------- */ +.hero { padding: clamp(28px, 6vw, 72px) 0 clamp(48px, 7vw, 96px); position: relative; } +.hero-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.04fr); gap: clamp(32px, 5vw, 80px); align-items: center; } +.kicker { + display: inline-flex; align-items: center; gap: 10px; + font: 500 .74rem/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; color: var(--ink-2); + padding: 8px 14px 8px 10px; border: 1px solid var(--rule); border-radius: 999px; background: color-mix(in srgb, var(--paper) 60%, #fff); +} +.kicker .live { width: 8px; height: 8px; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 0 rgba(214,60,27,.5); animation: pulse 2.2s infinite; } +@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(214,60,27,.45); } 70% { box-shadow: 0 0 0 9px rgba(214,60,27,0); } 100% { box-shadow: 0 0 0 0 rgba(214,60,27,0); } } +.display { + margin-top: 22px; + font-size: clamp(3.2rem, 7.4vw, 7.2rem); line-height: .92; letter-spacing: -.038em; font-weight: 740; +} +.display em { color: var(--red); font-size: 1.1em; letter-spacing: -.03em; line-height: .8; display: inline-block; padding-right: .06em; } +.display .line { display: block; } +.lede { margin-top: 26px; max-width: 34em; font-size: clamp(1.1rem, 1.5vw, 1.3rem); line-height: 1.5; color: var(--ink-2); } +.lede b { color: var(--ink); font-weight: 650; } +.cta-row { margin-top: 32px; display: flex; flex-wrap: wrap; gap: 12px; } +.stack { margin-top: 30px; display: flex; flex-wrap: wrap; gap: 8px 18px; font: 500 .78rem/1 var(--mono); color: var(--ink-3); } +.stack li { display: inline-flex; align-items: center; gap: 8px; } +.stack li::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ink); } +.stack li:nth-child(2n)::before { background: #fff; box-shadow: inset 0 0 0 1.2px var(--ink); } + +/* ---------- the board ---------- */ +.goban { + position: relative; border-radius: 10px; + background: var(--wood); + box-shadow: + 0 1px 0 rgba(255,255,255,.35) inset, + 0 -5px 0 rgba(120, 72, 20, .38) inset, + 0 2px 2px rgba(40, 25, 5, .12), + 0 26px 50px -22px rgba(64, 38, 6, .55), + 0 60px 80px -50px rgba(64, 38, 6, .35); + aspect-ratio: 1; + touch-action: manipulation; +} +.goban canvas { width: 100%; height: 100%; border-radius: inherit; } +.hero-board { margin: 0; } +.matchup { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; } +.player { display: flex; align-items: center; gap: 10px; min-width: 0; } +.player b { display: block; font-weight: 650; font-size: 1rem; letter-spacing: -.01em; line-height: 1.2; } +.player span { display: block; font: 500 .72rem/1.3 var(--mono); color: var(--ink-3); white-space: nowrap; } +.player.right { flex-direction: row-reverse; text-align: right; } +.vs { font: 400 1.4rem/1 var(--serif); font-style: italic; color: var(--ink-3); } +.stone { width: 22px; height: 22px; border-radius: 50%; flex: none; box-shadow: 0 2px 3px rgba(0,0,0,.25); } +.stone.b { background: radial-gradient(circle at 35% 30%, #5b5b58, #1b1a18 55%, #050505); } +.stone.w { background: radial-gradient(circle at 35% 30%, #fff, #f2eee5 50%, #cfc7b5); } +.player.thinking .stone { animation: think 1s var(--ease) infinite alternate; } +@keyframes think { to { transform: scale(1.12); box-shadow: 0 0 0 6px var(--red-soft), 0 2px 3px rgba(0,0,0,.25); } } + +.board-ctrls { position: absolute; right: 10px; bottom: 12px; display: flex; gap: 6px; opacity: 0; transition: opacity .25s; } +.goban:hover .board-ctrls, .board-ctrls:focus-within { opacity: 1; } +@media (hover: none) { .board-ctrls { opacity: .9; } } +.icon-btn { + width: 34px; height: 34px; border-radius: 50%; border: 0; cursor: pointer; + display: grid; place-items: center; + background: rgba(22, 20, 15, .78); color: var(--paper); + -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); + transition: background-color .2s, transform .2s var(--ease); +} +.icon-btn:hover { background: var(--ink); transform: scale(1.06); } +.icon-btn svg { width: 14px; height: 14px; } + +.winbar { margin-top: 18px; } +.winbar-track { position: relative; height: 12px; border-radius: 999px; background: #fbf9f4; box-shadow: inset 0 0 0 1px var(--rule); overflow: hidden; } +.winbar-b { position: absolute; inset: 0 auto 0 0; width: 50%; background: linear-gradient(90deg, #0d0c0a, #2a2823); border-radius: 999px 0 0 999px; transition: width .7s var(--ease); } +.winbar-mid { position: absolute; left: 50%; top: -3px; bottom: -3px; width: 1.5px; background: var(--red); opacity: .8; } +.winbar-labels { margin-top: 8px; display: flex; justify-content: space-between; font: 500 .74rem/1.3 var(--mono); color: var(--ink-3); } +.winbar-labels b { color: var(--ink); font-weight: 600; } +.hero-cap { margin-top: 14px; min-height: 3em; font: 400 .82rem/1.55 var(--mono); color: var(--ink-2); } +.hero-cap .mv { color: var(--red-deep); font-weight: 600; } +.hero-cap .muted { color: var(--ink-3); } + +/* ---------- scoreboard ---------- */ +.scores { border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); background: color-mix(in srgb, var(--paper) 70%, #fff); } +.scores-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); } +.score { padding: clamp(24px, 3.4vw, 40px) clamp(16px, 2vw, 28px); border-left: 1px solid var(--rule); } +.score:first-child { border-left: 0; padding-left: 0; } +.score-num { font-size: clamp(2.6rem, 5.4vw, 4.6rem); font-weight: 720; letter-spacing: -.05em; line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; } +.score-num small { font-size: .42em; letter-spacing: -.02em; font-weight: 600; margin-left: .12em; } +.score-num .dash { color: var(--red); } +.score-label { margin-top: 12px; font-size: 1rem; font-weight: 550; line-height: 1.35; } +.score-note { margin-top: 6px; font: 400 .74rem/1.45 var(--mono); color: var(--ink-3); } +@media (max-width: 860px) { + .scores-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .score:nth-child(3) { border-left: 0; padding-left: 0; } + .score:nth-child(n+3) { border-top: 1px solid var(--rule); } +} + +/* ---------- sections ---------- */ +.section { padding: clamp(72px, 10vw, 140px) 0; position: relative; } +.sec-head { max-width: 860px; } +.sec-num { display: flex; align-items: center; gap: 14px; font: 500 .74rem/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; color: var(--ink-2); } +.sec-num span { color: var(--red-deep); font-weight: 700; } +.sec-num::after { content: ""; flex: 1; height: 1px; background: var(--rule); max-width: 220px; } +.h2 { margin-top: 20px; font-size: clamp(2.3rem, 5.4vw, 4.6rem); line-height: .98; letter-spacing: -.035em; font-weight: 720; } +.h2 em { color: var(--red); font-size: 1.08em; letter-spacing: -.02em; } +.sec-lede { margin-top: 22px; max-width: 40em; font-size: clamp(1.05rem, 1.4vw, 1.2rem); line-height: 1.55; color: var(--ink-2); } + +/* ---------- loop ---------- */ +.loop-steps { margin-top: clamp(40px, 6vw, 72px); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(16px, 2.4vw, 32px); counter-reset: step; position: relative; } +.step { + position: relative; display: flex; flex-direction: column; + padding: 26px 26px 24px; border-radius: 18px; + background: color-mix(in srgb, var(--paper) 55%, #fff); + border: 1px solid var(--rule); + box-shadow: 0 1px 0 rgba(255,255,255,.6) inset, 0 18px 40px -30px rgba(40, 30, 10, .35); + transition: transform .35s var(--ease), box-shadow .35s var(--ease); +} +.step:hover { transform: translateY(-4px); box-shadow: 0 1px 0 rgba(255,255,255,.6) inset, 0 28px 50px -30px rgba(40, 30, 10, .45); } +.step-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } +.step-n { font-size: 3.4rem; font-weight: 760; letter-spacing: -.06em; line-height: .8; color: var(--ink); } +.step-n::after { content: "."; color: var(--red); } +.step-tag { font: 500 .7rem/1.3 var(--mono); color: var(--ink-3); text-align: right; } +.step h3 { margin-top: 22px; font-size: 1.55rem; letter-spacing: -.035em; line-height: 1.1; } +.step p { margin-top: 10px; color: var(--ink-2); font-size: .98rem; line-height: 1.55; } +.step-viz { margin-top: auto; padding-top: 22px; } +.viz-box { height: 168px; border-radius: 12px; background: var(--paper-2); border: 1px solid var(--rule-2); position: relative; overflow: hidden; display: grid; place-items: center; } +.viz-box .goban { width: 140px; border-radius: 6px; box-shadow: 0 -3px 0 rgba(120,72,20,.35) inset, 0 12px 20px -12px rgba(64,38,6,.55); } +.viz-cap { margin-top: 12px; font: 500 .7rem/1.4 var(--mono); color: var(--ink-3); display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; } +.viz-cap span { white-space: nowrap; } +.viz-cap b { color: var(--ink); font-weight: 650; } +.spark { width: 100%; height: 100%; } +.spark .area { fill: var(--red-soft); } +.spark .ln { fill: none; stroke: var(--red); stroke-width: 2.25; stroke-linecap: round; stroke-linejoin: round; } +.spark .pt { fill: var(--red); } +.spark text { font: 500 10.5px var(--mono); fill: var(--ink-3); } +.spark .hi { fill: var(--ink); font-weight: 700; } +.tally { width: 100%; padding: 0 18px; display: grid; gap: 14px; } +.tally-row { display: grid; gap: 6px; } +.tally-head { display: flex; justify-content: space-between; font: 500 .72rem/1 var(--mono); color: var(--ink-3); } +.tally-head b { color: var(--ink); } +.tally-dots { display: grid; grid-template-columns: repeat(20, 1fr); gap: 3px; } +.tally-dots i { aspect-ratio: 1; border-radius: 50%; background: var(--ink); transform: scale(0); transition: transform .35s var(--ease); } +.tally-dots i.l { background: transparent; box-shadow: inset 0 0 0 1.2px var(--ink-3); } +.in .tally-dots i { transform: scale(1); } + +.loop-return { margin-top: 18px; position: relative; height: 70px; } +.loop-return svg { width: 100%; height: 100%; overflow: visible; } +.loop-return .path { fill: none; stroke: var(--ink); stroke-width: 1.4; stroke-dasharray: 5 6; opacity: .55; } +.loop-return .head { fill: var(--ink); opacity: .55; } +.loop-return .runner { fill: var(--red); } +.loop-return-label { position: absolute; left: 50%; bottom: -6px; transform: translateX(-50%); background: var(--paper); padding: 4px 12px; font: 500 .76rem/1.3 var(--mono); color: var(--ink-2); white-space: nowrap; border-radius: 999px; } +.loop-return-label b { color: var(--red-deep); } +@media (max-width: 900px) { + .loop-steps { grid-template-columns: minmax(0, 1fr); } + .loop-return { height: auto; } + .loop-return svg { display: none; } + .loop-return-label { position: static; transform: none; display: block; text-align: center; background: transparent; white-space: normal; } +} + +/* ---------- generations ---------- */ +.gens { background: linear-gradient(var(--paper-2), var(--paper-2)) var(--grain); background-blend-mode: multiply; border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); } +.gen-grid { margin-top: clamp(36px, 5vw, 64px); display: grid; grid-template-columns: minmax(0, .95fr) minmax(0, 1fr); gap: clamp(28px, 4.5vw, 72px); align-items: start; } +.seg { display: inline-flex; padding: 4px; gap: 2px; border-radius: 999px; background: var(--paper-3); border: 1px solid var(--rule-2); } +.seg button { border: 0; background: transparent; cursor: pointer; padding: 8px 16px; border-radius: 999px; font-size: .88rem; font-weight: 550; color: var(--ink-2); transition: background-color .2s, color .2s, box-shadow .2s; white-space: nowrap; } +.seg button:hover { color: var(--ink); } +.seg button[aria-pressed="true"], .seg button[aria-checked="true"] { background: var(--paper); color: var(--ink); box-shadow: 0 1px 2px rgba(0,0,0,.08), 0 0 0 1px var(--rule-2); } +.gen-board-col .goban { margin-top: 18px; } +.gen-board-cap { margin-top: 16px; display: flex; justify-content: space-between; align-items: center; gap: 12px; font: 400 .8rem/1.5 var(--mono); color: var(--ink-2); min-height: 2.4em; } +.gen-board-cap .btn { flex: none; } +.gen-readout { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--ink); } +.gen-label { display: block; font: 500 .74rem/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; color: var(--ink-3); } +.gen-num { display: block; margin-top: 6px; font-size: clamp(4.4rem, 9vw, 7.6rem); line-height: .78; font-weight: 760; letter-spacing: -.07em; font-variant-numeric: tabular-nums; } +.gen-elo { text-align: right; } +.gen-elo b { display: block; margin-top: 8px; font-size: clamp(1.8rem, 3.2vw, 2.6rem); font-weight: 700; letter-spacing: -.04em; line-height: 1; font-variant-numeric: tabular-nums; } +.gen-elo small { display: block; margin-top: 4px; font: 400 .72rem/1 var(--mono); color: var(--ink-3); } +.gen-note { margin-top: 20px; font-size: 1.12rem; line-height: 1.5; min-height: 4.5em; color: var(--ink); } +.gen-note em { color: var(--red-deep); font-size: 1.12em; } +.gen-results { margin-top: 14px; display: flex; flex-wrap: wrap; gap: 6px; min-height: 30px; } +.chip { display: inline-flex; align-items: center; gap: 8px; height: 28px; padding: 0 11px; border-radius: 999px; border: 1px solid var(--rule); font: 500 .74rem/1 var(--mono); color: var(--ink-2); background: color-mix(in srgb, var(--paper) 60%, #fff); white-space: nowrap; } +.chip b { color: var(--ink); font-weight: 650; } +.chip.win { border-color: color-mix(in srgb, var(--ink) 50%, transparent); } +.chip.win::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ink); } +.chip.loss::before { content: ""; width: 7px; height: 7px; border-radius: 50%; box-shadow: inset 0 0 0 1.3px var(--ink-3); } +.chip.big { border-color: var(--red); color: var(--red-deep); } +.chip.big::before { background: var(--red); } + +.chart { margin-top: 26px; position: relative; } +.chart svg { width: 100%; height: auto; overflow: visible; touch-action: pan-y; cursor: pointer; } +.chart .grid line { stroke: var(--rule); } +.chart .axis text { font: 500 11px var(--mono); fill: var(--ink-3); } +.chart .anchor line { stroke: var(--ink-3); stroke-dasharray: 3 4; } +.chart .anchor text { font: 500 10.5px var(--mono); fill: var(--ink-2); } +.chart .elo-area { fill: url(#eloFill); } +.chart .elo-line { fill: none; stroke: var(--red); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; } +.chart .elo-pt { fill: var(--paper); stroke: var(--red); stroke-width: 2; transition: r .2s; } +.chart .elo-pt.on { fill: var(--red); } +.chart .cursor { stroke: var(--ink); stroke-width: 1; } +.chart .cursor-dot { fill: var(--red); stroke: var(--paper); stroke-width: 3; } +.chart .ci { fill: var(--red); opacity: .14; } + +.range-wrap { margin-top: 18px; } +.range { -webkit-appearance: none; appearance: none; width: 100%; height: 30px; background: transparent; cursor: pointer; margin: 0; } +.range::-webkit-slider-runnable-track { height: 4px; border-radius: 4px; background: linear-gradient(90deg, var(--ink) var(--p, 100%), var(--paper-3) var(--p, 100%)); } +.range::-moz-range-track { height: 4px; border-radius: 4px; background: var(--paper-3); } +.range::-moz-range-progress { height: 4px; border-radius: 4px; background: var(--ink); } +.range::-webkit-slider-thumb { -webkit-appearance: none; width: 26px; height: 26px; margin-top: -11px; border-radius: 50%; border: 0; background: radial-gradient(circle at 35% 30%, #5b5b58, #1b1a18 55%, #050505); box-shadow: 0 3px 6px rgba(0,0,0,.3), 0 0 0 4px var(--paper-2); } +.range::-moz-range-thumb { width: 26px; height: 26px; border-radius: 50%; border: 0; background: radial-gradient(circle at 35% 30%, #5b5b58, #1b1a18 55%, #050505); box-shadow: 0 3px 6px rgba(0,0,0,.3), 0 0 0 4px var(--paper-2); } +.range-ticks { display: flex; justify-content: space-between; padding: 0 9px; font: 500 .68rem/1 var(--mono); color: var(--ink-3); } + +.gen-strip { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: 18px 16px; } +.gen-strip-head { margin-top: clamp(48px, 6vw, 80px); display: flex; justify-content: space-between; align-items: baseline; gap: 16px; border-top: 1px solid var(--rule); padding-top: 16px; } +.gen-strip-head p { font: 500 .78rem/1.4 var(--mono); color: var(--ink-2); } +.gen-strip-head p + p { color: var(--ink-3); text-align: right; } +.gen-strip { margin-top: 18px; } +.thumb { border: 0; padding: 0; background: none; cursor: pointer; text-align: center; } +.thumb canvas { width: 100%; aspect-ratio: 1; border-radius: 4px; box-shadow: 0 -2px 0 rgba(120,72,20,.3) inset, 0 6px 12px -8px rgba(64,38,6,.6); transition: transform .25s var(--ease), box-shadow .25s; } +.thumb span { display: block; margin-top: 7px; font: 500 .7rem/1 var(--mono); color: var(--ink-3); transition: color .2s; } +.thumb:hover canvas { transform: translateY(-3px); } +.thumb[aria-pressed="true"] canvas { box-shadow: 0 0 0 2px var(--paper-2), 0 0 0 4px var(--red), 0 8px 16px -8px rgba(64,38,6,.6); } +.thumb[aria-pressed="true"] span { color: var(--red-deep); font-weight: 700; } +@media (max-width: 1000px) { + .gen-grid { grid-template-columns: minmax(0, 1fr); } + .gen-strip { grid-template-columns: repeat(6, minmax(0, 1fr)); } +} +@media (max-width: 560px) { + .gen-strip { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: 31%; gap: 12px; overflow-x: auto; scroll-snap-type: x mandatory; padding: 4px 4px 10px; margin: 14px -4px 0; scrollbar-width: none; } + .gen-strip::-webkit-scrollbar { display: none; } + .thumb { scroll-snap-align: start; } + .gen-strip-head { flex-direction: column; gap: 4px; } + .gen-strip-head p + p { text-align: left; } + .gen-board-cap { flex-direction: column; align-items: flex-start; } +} + +/* ---------- play (night) ---------- */ +.night { background: var(--night); color: var(--night-ink); position: relative; overflow: hidden; } +.night::before { content: ""; position: absolute; inset: 0; background: var(--grain); opacity: .5; mix-blend-mode: screen; pointer-events: none; } +.night .glow { position: absolute; width: 900px; height: 900px; left: -200px; top: 120px; background: radial-gradient(closest-side, rgba(226, 184, 114, .16), transparent); pointer-events: none; } +.night .sec-num { color: var(--night-ink-2); } +.night .sec-num span { color: #ff7b57; } +.night .sec-num::after { background: var(--night-rule); } +.night .h2 em { color: #ff6a43; } +.night .sec-lede { color: var(--night-ink-2); } +.play-grid { margin-top: clamp(36px, 5vw, 64px); display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: clamp(24px, 4vw, 56px); align-items: start; position: relative; } +.night .goban { box-shadow: 0 1px 0 rgba(255,255,255,.35) inset, 0 -6px 0 rgba(120,72,20,.5) inset, 0 40px 90px -30px rgba(0,0,0,.8), 0 0 0 1px rgba(255,255,255,.04); } +.play-board-col { position: relative; } +.play-overlay { + position: absolute; inset: 0; border-radius: 10px; display: grid; place-items: center; text-align: center; padding: 24px; + background: rgba(20, 18, 14, .22); + transition: opacity .4s; +} +#play-overlay-in { display: flex; flex-direction: column; align-items: center; gap: 12px; } +.play-overlay p { color: #fff; font-size: .95rem; } +.play-overlay .msg { font: 500 .76rem/1.4 var(--mono); color: #fff; background: rgba(20, 18, 14, .76); padding: 6px 12px; border-radius: 999px; } +.spinner { width: 34px; height: 34px; margin: 0 auto; border-radius: 50%; border: 3px solid rgba(255,255,255,.25); border-top-color: #fff; animation: spin .9s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +.panel { background: var(--night-2); border: 1px solid var(--night-rule); border-radius: 20px; padding: 26px; } +.pp-label { font: 500 .72rem/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; color: var(--night-ink-3); } +.pp-label + * { margin-top: 12px; } +.pp-block + .pp-block { margin-top: 26px; } +.side-pick { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.side { + display: flex; flex-direction: column; align-items: flex-start; gap: 12px; text-align: left; cursor: pointer; + padding: 16px; border-radius: 14px; border: 1.5px solid var(--night-rule); background: var(--night-3); color: var(--night-ink); + transition: border-color .2s, background-color .2s, transform .2s var(--ease); +} +.side:hover { border-color: rgba(243,238,228,.3); } +.side[aria-checked="true"] { border-color: #ff6a43; background: rgba(255, 106, 67, .08); } +.side .stone { width: 30px; height: 30px; } +.side b { font-weight: 650; font-size: 1.02rem; } +.side small { display: block; font: 400 .72rem/1.3 var(--mono); color: var(--night-ink-3); margin-top: 2px; } +.seg-dark { background: var(--night-3); border-color: var(--night-rule); width: 100%; display: grid; grid-template-columns: repeat(3, 1fr); } +.seg-dark button { color: var(--night-ink-2); padding: 10px 8px; text-align: center; } +.seg-dark button small { display: block; font: 400 .66rem/1.2 var(--mono); color: var(--night-ink-3); margin-top: 3px; } +.seg-dark button:hover { color: var(--night-ink); } +.seg-dark button[aria-checked="true"] { background: var(--night-ink); color: var(--night); box-shadow: none; } +.seg-dark button[aria-checked="true"] small { color: var(--ink-3); } +.btn-block { width: 100%; } +.pp-start { margin-top: 26px; } +.pp-fine { margin-top: 12px; font: 400 .72rem/1.5 var(--mono); color: var(--night-ink-3); text-align: center; } + +.pp-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.pp-turn { display: flex; align-items: center; gap: 10px; font-size: 1.3rem; font-weight: 650; letter-spacing: -.025em; } +.pp-turn .stone { width: 20px; height: 20px; } +.pp-turn.thinking .stone { animation: think 1s var(--ease) infinite alternate; } +.pp-progress { font: 500 .74rem/1 var(--mono); color: var(--night-ink-3); font-variant-numeric: tabular-nums; } +.pbar { margin-top: 14px; height: 3px; background: var(--night-3); border-radius: 3px; overflow: hidden; } +.pbar i { display: block; height: 100%; width: 0; background: #ff6a43; transition: width .2s linear; } +.night .winbar-track { background: #efe9dc; box-shadow: none; } +.night .winbar-labels { color: var(--night-ink-3); } +.night .winbar-labels b { color: var(--night-ink); } +.cand-head { margin-top: 22px; font: 500 .64rem/1 var(--mono) !important; letter-spacing: .1em; text-transform: uppercase; color: var(--night-ink-3) !important; } +.cands { margin-top: 12px; display: grid; gap: 7px; min-height: 172px; } +.cand { display: grid; grid-template-columns: 38px 1fr 54px; align-items: center; gap: 10px; font: 500 .8rem/1 var(--mono); color: var(--night-ink-2); font-variant-numeric: tabular-nums; } +.cand .mv { color: var(--night-ink); font-weight: 650; } +.cand .bar { height: 8px; border-radius: 8px; background: var(--night-3); overflow: hidden; } +.cand .bar i { display: block; height: 100%; background: var(--night-ink-2); border-radius: 8px; transition: width .3s var(--ease); } +.cand:first-child .bar i { background: #ff6a43; } +.cand .wr { text-align: right; } +.cands-empty { font: 400 .78rem/1.5 var(--mono); color: var(--night-ink-3); } +.pp-meta { margin: 22px 0 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--night-rule); border: 1px solid var(--night-rule); border-radius: 12px; overflow: hidden; } +.pp-meta div { background: var(--night-2); padding: 12px 14px; } +.pp-meta dt { font: 500 .66rem/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; color: var(--night-ink-3); } +.pp-meta dd { margin: 8px 0 0; font-size: 1.05rem; font-weight: 600; font-variant-numeric: tabular-nums; } +.pp-actions { margin-top: 20px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.btn-night { background: var(--night-3); color: var(--night-ink); border-color: var(--night-rule); } +.btn-night:hover { border-color: rgba(243,238,228,.35); } +.pp-result { margin-top: 18px; padding: 16px; border-radius: 14px; background: rgba(255, 106, 67, .1); border: 1px solid rgba(255, 106, 67, .4); font-size: 1.05rem; font-weight: 600; } +.pp-result small { display: block; margin-top: 4px; font: 400 .74rem/1.4 var(--mono); color: var(--night-ink-2); } +.play-foot { margin-top: 28px; display: flex; flex-wrap: wrap; gap: 10px 26px; font: 400 .76rem/1.5 var(--mono); color: var(--night-ink-3); position: relative; } +.play-foot b { color: var(--night-ink-2); font-weight: 600; } +@media (max-width: 900px) { + .play-grid { grid-template-columns: minmax(0, 1fr); } +} + +/* ---------- engine ---------- */ +.engine-grid { margin-top: clamp(40px, 5vw, 64px); display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(28px, 4.5vw, 72px); align-items: start; } +.specs { margin: 0; border-top: 1px solid var(--ink); } +.spec { display: grid; grid-template-columns: 116px minmax(0, 1fr); gap: 16px; padding: 18px 0; border-bottom: 1px solid var(--rule); } +.spec dt { font-weight: 680; font-size: 1.02rem; letter-spacing: -.015em; } +.spec dd { margin: 0; color: var(--ink-2); font-size: .97rem; line-height: 1.55; } +.spec .metric { display: inline-block; margin-top: 8px; font: 600 .74rem/1 var(--mono); color: var(--red-deep); background: var(--red-soft); padding: 6px 9px; border-radius: 6px; } +.spec code, .quick code, .gen-note code { font: 500 .88em var(--mono); } +@media (max-width: 480px) { .spec { grid-template-columns: minmax(0, 1fr); gap: 6px; } } + +.term { border-radius: 16px; background: #15130f; color: #e9e3d6; overflow: hidden; box-shadow: 0 40px 70px -40px rgba(30, 20, 5, .6), 0 0 0 1px rgba(0,0,0,.4); position: sticky; top: 90px; } +.term-bar { display: flex; align-items: center; gap: 14px; padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,.07); background: #1b1813; } +.term-dots { display: flex; gap: 6px; flex: none; } +.term-dots i { width: 10px; height: 10px; border-radius: 50%; background: #3a352c; } +.term-dots i:first-child { background: var(--red); } +.term-tabs { display: flex; gap: 4px; overflow-x: auto; scrollbar-width: none; } +.term-tabs::-webkit-scrollbar { display: none; } +.term-tabs button { border: 0; background: transparent; color: #8e8676; font: 500 .74rem/1 var(--mono); padding: 7px 10px; border-radius: 7px; cursor: pointer; white-space: nowrap; transition: color .2s, background-color .2s; } +.term-tabs button:hover { color: #e9e3d6; } +.term-tabs button[aria-selected="true"] { background: #2a261f; color: #fff; } +.term-title { padding: 10px 16px 0; font: 400 .7rem/1 var(--mono); color: #6f6858; } +.term pre { margin: 0; padding: 12px 16px 18px; height: 400px; overflow: auto; font: 400 .78rem/1.6 var(--mono); white-space: pre; scrollbar-width: thin; scrollbar-color: #3a352c transparent; } +.term .t-cmd { color: #fff; } +.term .t-cmd::before { content: "$ "; color: #ff7b57; } +.term .t-cmd2 { color: #fff; } +.term .t-in { color: #fff; } +.term .t-in::before { content: "> "; color: #ff7b57; } +.term .t-gold { color: #f0b46a; } +.term .t-com { color: #6f6858; } +.term .t-dim { color: #a59d8b; } +.term .t-out { color: #d6cfc0; } +.caret::after { content: ""; display: inline-block; width: .55em; height: 1.05em; margin-left: 1px; vertical-align: -.18em; background: #ff7b57; animation: blink 1s steps(1) infinite; } +@keyframes blink { 50% { opacity: 0; } } + +.quick { margin-top: 26px; } +.quick-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.quick-box { margin-top: 10px; border-radius: 14px; border: 1px solid var(--rule); background: color-mix(in srgb, var(--paper) 50%, #fff); } +.quick pre { margin: 0; padding: 16px 18px; overflow-x: auto; font: 400 .8rem/1.75 var(--mono); color: var(--ink); } +.quick pre .c { color: var(--ink-3); } +.copy { width: 34px; height: 34px; border-radius: 9px; border: 1px solid var(--rule); background: var(--paper); cursor: pointer; display: grid; place-items: center; color: var(--ink-2); transition: color .2s, border-color .2s; } +.copy:hover { color: var(--ink); border-color: var(--ink); } +.copy svg { width: 15px; height: 15px; } +.copy.done { color: var(--red-deep); border-color: var(--red); } +@media (max-width: 1000px) { + .engine-grid { grid-template-columns: minmax(0, 1fr); } + .term { position: relative; top: 0; } + .term pre { height: 360px; } +} + +/* ---------- footer ---------- */ +.foot { background: var(--ink); color: var(--paper); padding: clamp(56px, 8vw, 96px) 0 28px; position: relative; overflow: hidden; } +.foot::before { content: ""; position: absolute; inset: 0; background: var(--grain); opacity: .45; mix-blend-mode: screen; pointer-events: none; } +.foot-top { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 40px; position: relative; } +.foot-tag { font-size: clamp(2rem, 4vw, 3.2rem); font-weight: 720; letter-spacing: -.045em; line-height: 1; } +.foot-tag em { color: #ff6a43; font-size: 1.1em; } +.foot-sub { margin-top: 16px; max-width: 30em; color: #b9b1a1; font-size: .98rem; } +.foot-links { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; } +.foot-links h4 { margin: 0 0 12px; font: 500 .7rem/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; color: #8e8676; } +.foot-links a { display: block; padding: 4px 0; text-decoration: none; color: #e9e3d6; font-size: .95rem; transition: color .2s; } +.foot-links a:hover { color: #ff7b57; } +.foot-word { margin-top: clamp(40px, 6vw, 80px); padding-bottom: .12em; font-size: clamp(6rem, 24vw, 21rem); font-weight: 760; letter-spacing: -.045em; line-height: .8; color: var(--paper); position: relative; user-select: none; white-space: nowrap; } +.foot-word .seal { display: inline-block; width: .17em; height: .17em; border-radius: 50%; background: var(--red); margin-left: .03em; } +.foot-bottom { margin-top: 32px; padding-top: 18px; border-top: 1px solid rgba(243,238,228,.14); display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px 24px; font: 400 .72rem/1.5 var(--mono); color: #8e8676; position: relative; } +@media (max-width: 760px) { + .foot-top { grid-template-columns: minmax(0, 1fr); } + .foot-links { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +/* ---------- reveal ---------- */ +.rv { opacity: 0; transform: translateY(22px); transition: opacity .8s var(--ease), transform .8s var(--ease); transition-delay: var(--d, 0s); } +.rv.in { opacity: 1; transform: none; } +.no-js .rv { opacity: 1; transform: none; } +@media (prefers-reduced-motion: reduce) { + .rv { opacity: 1; transform: none; transition: none; } + .kicker .live, .player.thinking .stone, .pp-turn.thinking .stone { animation: none; } + .tally-dots i { transform: scale(1); transition: none; } +} + +/* ---------- small screens ---------- */ +@media (max-width: 960px) { + .hero-grid { grid-template-columns: minmax(0, 1fr); } + .hero-board { max-width: 620px; width: 100%; margin: 0 auto; } +} +@media (max-width: 480px) { + .kicker { font-size: .64rem; letter-spacing: .1em; } + .btn-lg { --h: 50px; } + .cta-row .btn { flex: 1 1 auto; } + .player span { font-size: .66rem; } + .panel { padding: 20px; } +} diff --git a/web/site.js b/web/site.js new file mode 100644 index 0000000..23d0787 --- /dev/null +++ b/web/site.js @@ -0,0 +1,967 @@ +/* Gofer landing page. No framework: canvas boards, inline SVG charts and the + real engine (engine.js) for the play section. Data comes from web/data/, + written by `go run -tags onnx ./cmd/sitedata`. */ +(function () { + 'use strict'; + + const EMPTY = 0, BLACK = 1, WHITE = 2, PASS = -1, SIZE = 9, N = SIZE * SIZE, KOMI = 7.5; + const COLS = 'ABCDEFGHJ'; + const RED = '#d63c1b'; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const $ = id => document.getElementById(id); + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const clamp = (x, a, b) => Math.max(a, Math.min(b, x)); + const pct = x => Math.round(100 * x) + '%'; + const vertex = p => p < 0 ? 'pass' : COLS[p % SIZE] + (SIZE - ((p / SIZE) | 0)); + const fetchJSON = url => fetch(url).then(r => { if (!r.ok) throw new Error(url + ': HTTP ' + r.status); return r.json(); }); + + function onVisible(el, fn, opts) { + const io = new IntersectionObserver(es => es.forEach(e => fn(e.isIntersecting, e)), opts || { threshold: 0.25 }); + io.observe(el); + return io; + } + + /* ============================================================ + rules, enough to replay recorded games + ============================================================ */ + const NB = []; + for (let p = 0; p < N; p++) { + const x = p % SIZE, y = (p / SIZE) | 0, l = []; + if (x > 0) l.push(p - 1); + if (x < SIZE - 1) l.push(p + 1); + if (y > 0) l.push(p - SIZE); + if (y < SIZE - 1) l.push(p + SIZE); + NB.push(l); + } + function chain(cells, p) { + const c = cells[p], seen = new Set([p]), stack = [p]; + let libs = 0; + const libSeen = new Set(); + while (stack.length) { + const q = stack.pop(); + for (const r of NB[q]) { + if (cells[r] === EMPTY) { if (!libSeen.has(r)) { libSeen.add(r); libs++; } } + else if (cells[r] === c && !seen.has(r)) { seen.add(r); stack.push(r); } + } + } + return { stones: seen, libs }; + } + // play returns a new position and the number of stones it captured. + function play(cells, p, color) { + const next = Int8Array.from(cells); + if (p < 0) return { cells: next, captured: 0 }; + next[p] = color; + let captured = 0; + for (const r of NB[p]) { + if (next[r] === 3 - color) { + const ch = chain(next, r); + if (ch.libs === 0) for (const q of ch.stones) { next[q] = EMPTY; captured++; } + } + } + const own = chain(next, p); + if (own.libs === 0) for (const q of own.stones) next[q] = EMPTY; + return { cells: next, captured }; + } + // replay turns a move list into the positions after every move. + function replay(moves, first) { + let cells = new Int8Array(N), color = first || BLACK; + const out = [{ cells, last: -2, capB: 0, capW: 0 }]; + let capB = 0, capW = 0; + for (const m of moves) { + const r = play(cells, m, color); + if (color === BLACK) capB += r.captured; else capW += r.captured; + cells = r.cells; + out.push({ cells, last: m, capB, capW }); + color = 3 - color; + } + return out; + } + + /* ============================================================ + the board renderer + ============================================================ */ + function rng(seed) { + return function () { + seed |= 0; seed = seed + 0x6D2B79F5 | 0; + let t = Math.imul(seed ^ seed >>> 15, 1 | seed); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + } + + const woodCache = new Map(); + function wood(w, seed) { + const key = w + ':' + seed; + if (woodCache.has(key)) return woodCache.get(key); + const c = document.createElement('canvas'); + c.width = c.height = w; + const g = c.getContext('2d'), r = rng(seed), k = w / 600; + const base = g.createLinearGradient(0, 0, w, w * .8); + base.addColorStop(0, '#ebc684'); base.addColorStop(.55, '#e2b670'); base.addColorStop(1, '#d6a65c'); + g.fillStyle = base; g.fillRect(0, 0, w, w); + for (let i = 0; i < 110; i++) { + const y0 = r() * w * 1.1 - w * .05, amp = (2 + r() * 7) * k, f = (1 + r() * 2.2) * Math.PI * 2 / w, ph = r() * 6.3; + const dark = r() < .62; + g.strokeStyle = dark ? `rgba(150, 92, 30, ${.035 + r() * .075})` : `rgba(255, 238, 196, ${.05 + r() * .08})`; + g.lineWidth = (.5 + r() * 2.4) * k; + g.beginPath(); + for (let x = -4; x <= w + 4; x += w / 48) { + const y = y0 + Math.sin(x * f + ph) * amp + Math.sin(x * f * 2.9 + ph * 1.7) * amp * .3 + x * .05; + if (x < 0) g.moveTo(x, y); else g.lineTo(x, y); + } + g.stroke(); + } + const v = g.createRadialGradient(w * .42, w * .38, w * .05, w * .5, w * .5, w * .78); + v.addColorStop(0, 'rgba(255, 244, 214, .22)'); v.addColorStop(1, 'rgba(110, 62, 14, .2)'); + g.fillStyle = v; g.fillRect(0, 0, w, w); + woodCache.set(key, c); + return c; + } + + const spriteCache = new Map(); + function stoneSprite(r, color) { + const key = Math.round(r * 4) + ':' + color; + if (spriteCache.has(key)) return spriteCache.get(key); + const pad = Math.ceil(r * .6), s = Math.ceil(2 * r + 2 * pad); + const c = document.createElement('canvas'); + c.width = c.height = s; + const g = c.getContext('2d'), cx = s / 2, cy = s / 2; + g.save(); + g.shadowColor = 'rgba(48, 24, 0, .5)'; g.shadowBlur = r * .38; g.shadowOffsetX = r * .07; g.shadowOffsetY = r * .17; + g.beginPath(); g.arc(cx, cy, r * .97, 0, Math.PI * 2); + g.fillStyle = color === BLACK ? '#111' : '#d8d1c1'; g.fill(); + g.restore(); + const gr = g.createRadialGradient(cx - r * .36, cy - r * .4, r * .04, cx, cy, r * 1.02); + if (color === BLACK) { + gr.addColorStop(0, '#6d6c68'); gr.addColorStop(.22, '#33322e'); gr.addColorStop(.7, '#121110'); gr.addColorStop(1, '#040404'); + } else { + gr.addColorStop(0, '#ffffff'); gr.addColorStop(.45, '#f7f4ed'); gr.addColorStop(.86, '#ddd6c6'); gr.addColorStop(1, '#c2b8a3'); + } + g.beginPath(); g.arc(cx, cy, r, 0, Math.PI * 2); g.fillStyle = gr; g.fill(); + if (color === WHITE) { + // clamshell grain + g.save(); g.clip(); + g.strokeStyle = 'rgba(150, 138, 112, .09)'; g.lineWidth = Math.max(.6, r * .045); + for (let i = 0; i < 7; i++) { g.beginPath(); g.arc(cx + r * 1.15, cy - r * .55, r * (.55 + i * .26), 0, Math.PI * 2); g.stroke(); } + g.restore(); + } else { + g.beginPath(); g.ellipse(cx - r * .32, cy - r * .42, r * .3, r * .16, -.6, 0, Math.PI * 2); + g.fillStyle = 'rgba(255, 255, 255, .1)'; g.fill(); + } + const out = { c, s }; + spriteCache.set(key, out); + return out; + } + + const easeOut = t => 1 - Math.pow(1 - t, 3); + const easeBack = t => { const c1 = 1.5, c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); }; + + class Goban { + constructor(canvas, opts) { + opts = opts || {}; + this.cv = canvas; + this.ctx = canvas.getContext('2d'); + this.coords = opts.coords !== false; + this.lite = !!opts.lite; + this.seed = opts.seed || 7; + this.cells = new Int8Array(N); + this.anim = new Map(); // point -> start time of its placement + this.fading = []; // {p, color, t0} + this.last = -2; + this.marks = []; // candidate rings {p, label, w (0..1), t0} + this.heat = null; this.heatFrom = null; this.heatT0 = 0; + this.ghost = null; // {p, color} + this.raf = 0; + this.w = 0; + new ResizeObserver(() => this.resize()).observe(canvas); + this.resize(); + } + resize() { + const rect = this.cv.getBoundingClientRect(); + if (!rect.width) return; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = Math.round(rect.width * dpr); + if (w === this.w) return; + this.w = w; + this.cv.width = this.cv.height = w; + const mf = this.coords ? 1.02 : .62; + this.cell = w / (SIZE - 1 + 2 * mf); + this.m = this.cell * mf; + this.r = this.cell * .485; + this.draw(performance.now()); + } + xy(p) { return [this.m + (p % SIZE) * this.cell, this.m + ((p / SIZE) | 0) * this.cell]; } + pointAt(ev) { + const rect = this.cv.getBoundingClientRect(), k = this.w / rect.width; + const x = (ev.clientX - rect.left) * k, y = (ev.clientY - rect.top) * k; + const cx = Math.round((x - this.m) / this.cell), cy = Math.round((y - this.m) / this.cell); + if (cx < 0 || cy < 0 || cx >= SIZE || cy >= SIZE) return -1; + return cy * SIZE + cx; + } + // set shows a position. animate drops new stones in and fades captured ones + // out; opts.stagger spreads the drops over that many ms, opts.fade=false + // removes stones at once (used when switching between unrelated positions). + set(cells, last, animate, opts) { + const now = performance.now(), stagger = (opts && opts.stagger) || 0, fade = !opts || opts.fade !== false; + if (!fade) this.fading = []; + for (let p = 0; p < N; p++) { + const a = this.cells[p], b = cells[p]; + if (a === b) continue; + if (a !== EMPTY && animate && fade) this.fading.push({ p, color: a, t0: now }); + if (b !== EMPTY && animate) this.anim.set(p, now + Math.random() * stagger); else this.anim.delete(p); + } + this.cells = Int8Array.from(cells); + this.last = last === undefined ? -2 : last; + this.kick(); + } + setMarks(marks) { + const now = performance.now(), old = new Map(this.marks.map(m => [m.p, m])); + this.marks = marks.map((m, i) => Object.assign({}, m, { t0: old.has(m.p) ? old.get(m.p).t0 : now + i * 70 })); + this.kick(); + } + setHeat(values) { + this.heatFrom = this.heatNow(performance.now()) || new Array(N).fill(0); + this.heat = values; this.heatT0 = performance.now(); + this.kick(); + } + heatNow(now) { + if (!this.heat) return null; + const t = reduceMotion ? 1 : clamp((now - this.heatT0) / 450, 0, 1), e = easeOut(t), from = this.heatFrom; + if (!from || t >= 1) return this.heat; + return this.heat.map((v, i) => from[i] + (v - from[i]) * e); + } + setGhost(g) { + const same = (g && this.ghost && g.p === this.ghost.p && g.color === this.ghost.color) || (!g && !this.ghost); + if (same) return; + this.ghost = g; this.kick(); + } + kick() { + if (this.raf) return; + this.raf = requestAnimationFrame(t => { this.raf = 0; if (this.draw(t)) this.kick(); }); + } + draw(now) { + if (!this.w) return false; + const g = this.ctx, w = this.w, cell = this.cell, m = this.m; + let busy = false; + g.clearRect(0, 0, w, w); + g.drawImage(wood(w, this.seed), 0, 0); + + // grid + const lw = Math.max(1, w / (this.lite ? 220 : 560)); + g.strokeStyle = 'rgba(52, 32, 8, .78)'; g.lineWidth = lw; + g.beginPath(); + for (let i = 0; i < SIZE; i++) { + const o = Math.round(m + i * cell) + (lw % 2 ? .5 : 0); + g.moveTo(m, o); g.lineTo(m + (SIZE - 1) * cell, o); + g.moveTo(o, m); g.lineTo(o, m + (SIZE - 1) * cell); + } + g.stroke(); + g.lineWidth = lw * 1.8; + g.strokeRect(m, m, (SIZE - 1) * cell, (SIZE - 1) * cell); + g.fillStyle = 'rgba(52, 32, 8, .85)'; + for (const p of [20, 24, 40, 56, 60]) { const [x, y] = this.xy(p); g.beginPath(); g.arc(x, y, Math.max(1.5, cell * .075), 0, Math.PI * 2); g.fill(); } + if (this.coords) { + g.fillStyle = 'rgba(70, 44, 12, .62)'; + g.font = `500 ${Math.round(cell * .22)}px "JetBrains Mono", ui-monospace, monospace`; + g.textAlign = 'center'; g.textBaseline = 'middle'; + for (let i = 0; i < SIZE; i++) { + g.fillText(COLS[i], m + i * cell, w - m * .3); + g.fillText(String(SIZE - i), m * .3, m + i * cell); + } + } + + // heat (raw policy bubbles) + const heat = this.heatNow(now); + if (heat) { + if (heat !== this.heat) busy = true; + for (let p = 0; p < N; p++) { + const v = heat[p]; + if (v <= .005 || this.cells[p]) continue; + const [x, y] = this.xy(p); + g.beginPath(); g.arc(x, y, this.r * .96 * v, 0, Math.PI * 2); + g.fillStyle = `rgba(214, 60, 27, ${.55 + .4 * v})`; g.fill(); + } + } + + // fading (captured) stones + this.fading = this.fading.filter(f => { + const t = (now - f.t0) / 320; + if (t >= 1 || reduceMotion) return false; + const [x, y] = this.xy(f.p); + g.globalAlpha = 1 - easeOut(t); + this.stone(x, y, f.color, 1 - .15 * t); + g.globalAlpha = 1; + busy = true; + return true; + }); + + // stones + for (let p = 0; p < N; p++) { + const c = this.cells[p]; + if (!c) continue; + const [x, y] = this.xy(p); + const t0 = this.anim.get(p); + if (t0 !== undefined && !reduceMotion) { + const t = clamp((now - t0) / 280, 0, 1); + if (t >= 1) this.anim.delete(p); else busy = true; + g.globalAlpha = clamp(t * 3, 0, 1); + this.stone(x, y, c, 1 + .22 * (1 - easeBack(t))); + g.globalAlpha = 1; + } else this.stone(x, y, c, 1); + } + + // ghost + if (this.ghost && this.ghost.p >= 0 && !this.cells[this.ghost.p]) { + const [x, y] = this.xy(this.ghost.p); + g.globalAlpha = .45; this.stone(x, y, this.ghost.color, 1); g.globalAlpha = 1; + } + + // candidate rings + for (const mk of this.marks) { + if (mk.p < 0) continue; + const t = reduceMotion ? 1 : clamp((now - mk.t0) / 260, 0, 1); + if (t < 1) busy = true; + if (t <= 0) continue; + const [x, y] = this.xy(mk.p), s = easeBack(t), rr = this.r * .78 * s; + g.beginPath(); g.arc(x, y, rr, 0, Math.PI * 2); + g.fillStyle = mk.best ? 'rgba(214, 60, 27, .92)' : `rgba(255, 250, 240, ${.55 + .3 * (mk.w || 0)})`; + g.fill(); + g.lineWidth = Math.max(1.5, cell * .045); g.strokeStyle = RED; g.stroke(); + if (mk.label && cell > 28) { + g.fillStyle = mk.best ? '#fff' : '#7a2410'; + g.font = `700 ${Math.round(cell * .25)}px "JetBrains Mono", ui-monospace, monospace`; + g.textAlign = 'center'; g.textBaseline = 'middle'; + g.fillText(mk.label, x, y + 1); + } + } + + // last move + if (this.last >= 0 && this.cells[this.last]) { + const [x, y] = this.xy(this.last); + g.beginPath(); g.arc(x, y, this.r * .3, 0, Math.PI * 2); + g.fillStyle = RED; g.fill(); + if (this.cells[this.last] === BLACK) { g.lineWidth = Math.max(1, cell * .02); g.strokeStyle = 'rgba(0,0,0,.35)'; g.stroke(); } + } + return busy; + } + stone(x, y, color, scale) { + const sp = stoneSprite(this.r, color), s = sp.s * scale; + this.ctx.drawImage(sp.c, x - s / 2, y - s / 2, s, s); + } + } + + /* ============================================================ + win bar + ============================================================ */ + function winbar(fill, bLabel, wLabel) { + return function (pb) { + pb = clamp(pb, 0, 1); + fill.style.width = (100 * pb).toFixed(1) + '%'; + bLabel.textContent = pct(pb); + wLabel.textContent = pct(1 - pb); + }; + } + + /* ============================================================ + hero: Gofer's own analysis of a game it won against GNU Go + ============================================================ */ + async function hero() { + const data = await fetchJSON('data/hero.json'); + const board = new Goban($('hero-board'), { coords: true, seed: 3 }); + const cap = $('hero-cap'), pb = $('hero-pb'), pw = $('hero-pw'); + const setWin = winbar($('hero-wb'), $('hero-wb-b'), $('hero-wb-w')); + const moves = data.moves.map(f => f.p); + const pos = replay(moves); + const result = data.result || ''; + let k = 0, playing = false, visible = false, timer = 0, token = 0; + + const who = c => c === 'B' ? 'GNU Go' : 'Gofer'; + function cands(f) { + const total = f.top.reduce((a, c) => a + c.n, 0) || 1; + return f.top.filter(c => c.p >= 0).slice(0, 4).map((c, i) => ({ p: c.p, label: String(Math.round(100 * c.n / total)), w: c.n / total, best: i === 0 })); + } + function think(i) { + const f = data.moves[i]; + board.set(pos[i].cells, pos[i].last, false); + board.setMarks(cands(f)); + setWin(f.bw); + pb.classList.toggle('thinking', f.c === 'B'); pw.classList.toggle('thinking', f.c === 'W'); + const total = f.top.reduce((a, c) => a + c.n, 0) || 1; + const list = f.top.slice(0, 3).map(c => `${vertex(c.p)} ${Math.round(100 * c.n / total)}%`).join(' · '); + cap.innerHTML = f.c === 'W' + ? `Move ${i + 1} · Gofer (White) is weighing ${list}` + : `Move ${i + 1} · GNU Go to play. Gofer expects ${list}`; + } + function place(i) { + const f = data.moves[i]; + board.setMarks([]); + board.set(pos[i + 1].cells, f.p, true); + pb.classList.remove('thinking'); pw.classList.remove('thinking'); + const rank = f.top.findIndex(c => c.p === f.p); + let note; + if (f.c === 'W') note = f.p < 0 ? 'Gofer passes' : `Gofer plays ${vertex(f.p)}`; + else if (f.p < 0) note = 'GNU Go passes'; + else note = `GNU Go plays ${vertex(f.p)}` + (rank === 0 ? ', exactly as Gofer expected' : rank > 0 ? `, Gofer's #${rank + 1} guess` : ", outside Gofer's top five"); + const after = data.moves[i + 1]; + if (after) setWin(after.bw); + cap.innerHTML = `Move ${i + 1} · ${note}`; + } + function finale() { + pb.classList.remove('thinking'); pw.classList.remove('thinking'); + board.setMarks([]); + board.set(pos[moves.length].cells, -2, false); + setWin(result.startsWith('W') ? 0 : 1); + cap.innerHTML = `Gofer wins by ${result.replace(/^[BW]\+/, '')} (${result}). It won 66 of 100 games like this one against GNU Go's default level.`; + } + async function run() { + const my = ++token; + while (playing && my === token) { + if (k >= moves.length) { + finale(); + await sleep(5200); + if (!playing || my !== token) return; + k = 0; + board.set(new Int8Array(N), -2, false); + await sleep(500); + continue; + } + think(k); + await sleep(data.moves[k].c === 'W' ? 1150 : 800); + if (!playing || my !== token) return; + place(k); + k++; + await sleep(560); + } + } + function setPlaying(on) { + playing = on; + $('hero-toggle').setAttribute('aria-label', on ? 'Pause' : 'Play'); + $('hero-toggle').innerHTML = on + ? '' + : ''; + if (on) run(); else token++; + } + let userPaused = reduceMotion; + $('hero-toggle').addEventListener('click', () => { + if (playing) { userPaused = true; setPlaying(false); } else { userPaused = false; setPlaying(true); } + }); + $('hero-restart').addEventListener('click', () => { + k = 0; board.set(new Int8Array(N), -2, false); board.setMarks([]); setWin(.5); + userPaused = false; token++; setPlaying(true); + }); + onVisible($('hero-goban'), v => { + visible = v && !document.hidden; + if (visible && !userPaused && !playing) setPlaying(true); + if (!visible && playing) setPlaying(false); + }, { threshold: .3 }); + document.addEventListener('visibilitychange', () => { + if (document.hidden && playing) setPlaying(false); + else if (!document.hidden && visible && !userPaused && !playing) setPlaying(true); + }); + if (reduceMotion) { finale(); setPlaying(false); } + else { think(0); } + } + + /* ============================================================ + count-up numbers + ============================================================ */ + function countUps() { + document.querySelectorAll('[data-count]').forEach(el => { + const target = parseFloat(el.dataset.count), dec = +(el.dataset.dec || 0); + const fmt = x => x.toLocaleString('en-US', { minimumFractionDigits: dec, maximumFractionDigits: dec }); + if (reduceMotion) { el.textContent = fmt(target); return; } + let done = false; + const io = onVisible(el, v => { + if (!v || done) return; + done = true; io.disconnect(); + const t0 = performance.now(), dur = 1300; + el.textContent = fmt(0); + (function tick(t) { + const e = easeOut(clamp((t - t0) / dur, 0, 1)); + el.textContent = fmt(dec ? Math.round(target * e * 10 ** dec) / 10 ** dec : Math.round(target * e)); + if (e < 1) requestAnimationFrame(tick); + })(t0); + }, { threshold: .6 }); + }); + } + + /* ============================================================ + 01 the loop + ============================================================ */ + function loopSection(gens) { + // a self-play game of the final network, played on a loop + const mini = new Goban($('loop-board'), { coords: false, lite: true, seed: 11 }); + const game = gens[gens.length - 1].game; + const pos = replay(game.moves); + let i = 0, on = false, tok = 0; + async function spin() { + const my = ++tok; + while (on && my === tok) { + if (i >= pos.length) { await sleep(1600); i = 0; continue; } + mini.set(pos[i].cells, pos[i].last, i > 0); + i++; + await sleep(i === 1 ? 500 : 150); + } + } + if (reduceMotion) mini.set(pos[pos.length - 1].cells, -2, false); + else onVisible($('loop-board'), v => { on = v; if (v) spin(); else tok++; }); + + // policy loss by generation + const svg = $('loss-spark'); + const pts = gens.filter(g => g.policyLoss).map(g => [g.gen, g.policyLoss]); + const W = 300, H = 150, px = 18, py = 22; + const lo = 2.2, hi = 4.6; + const X = gx => px + (gx / 16) * (W - 2 * px), Y = v => py + (hi - v) / (hi - lo) * (H - 2 * py); + const d = pts.map((p, i) => (i ? 'L' : 'M') + X(p[0]).toFixed(1) + ' ' + Y(p[1]).toFixed(1)).join(' '); + const first = pts[0], lastp = pts[pts.length - 1]; + svg.setAttribute('viewBox', `0 0 ${W} ${H}`); + svg.innerHTML = + `` + + `` + + `` + + `${first[1].toFixed(2)}` + + `gen 0: random` + + `${lastp[1].toFixed(2)}` + + `gen 16`; + onVisible(svg, v => { if (v) svg.querySelector('.ln').style.strokeDashoffset = '0'; }); + + // generation 16's report card + const g16 = gens[16]; + const rows = (g16.results || []).filter(r => r.opp === 'gen-0015' || r.opp === 'gnugo-l6'); + const tally = $('tally'); + tally.innerHTML = rows.map(r => { + const dots = []; + for (let i = 0; i < r.w + r.l; i++) dots.push(``); + return `
    vs ${oppName(r.opp)}${r.w}–${r.l}
    ${dots.join('')}
    `; + }).join(''); + } + + function oppName(o) { + if (o.startsWith('gen-')) return 'generation ' + (+o.slice(4)); + if (o === 'rollout') return 'Monte-Carlo'; + const m = /^gnugo-l(\d+)$/.exec(o); + return m ? 'GNU Go L' + m[1] : o; + } + + /* ============================================================ + 02 watch it learn + ============================================================ */ + const NOTES = [ + 'Random weights: it knows the rules and nothing else. Every point looks equally good, the edge included, and its games end in a tangle of stones.', + 'One lap in, it already leans away from the edge. It wins all 40 games against generation 0 and loses all 80 against GNU Go.', + 'Stronger again, but GNU Go still wins every game.', + 'A big jump to +1670, rated above a plain Monte-Carlo player, with a sudden taste for the second line.', + 'Its first wins against GNU Go: 4 of 40 at level 1 and 3 of 40 at level 6.', + 'Policy loss keeps falling: it is getting better at predicting its own search.', + 'Beats generation 5 in 31 of 40 games, and wins 6 of 40 against GNU Go level 6.', + 'Elo +1975. The first line has almost vanished from its opening instinct.', + '8 wins out of 40 against both GNU Go levels.', + 'A step back. It loses to generation 8, 16–24. Self-play is noisy; the loop keeps going anyway.', + 'Back on track: level with GNU Go level 1 at 19–21, and 40–0 against the Monte-Carlo player.', + 'Beats generation 10, 22–18.', + 'Overtakes GNU Go level 1, 26–14, and splits 20–20 with level 6.', + 'Elo +2297, the best rating so far.', + 'Another dip: it loses to generation 13, 17–23, though it still beats GNU Go level 1.', + 'Beats generation 14, 23–17.', + 'Now it beats GNU Go level 6 as well, 23–17.', + 'The released network, models/gofer-9x9.onnx. With 400 visits a move it beats GNU Go’s default level 66–34. Its favourite openings: the centre and the 4-4 points.', + ]; + + function gensSection(data) { + const gens = data.gens, last = gens.length - 1; + const board = new Goban($('gen-board'), { coords: true, seed: 5 }); + const games = gens.map(g => replay(g.game ? g.game.moves : [])); + const range = $('gen-range'), strip = $('gen-strip'); + let cur = -1, view = 'game', replayTok = 0, introTok = 0, userTouched = false; + + // thumbnails + gens.forEach((g, i) => { + const b = document.createElement('button'); + b.type = 'button'; b.className = 'thumb'; b.setAttribute('aria-pressed', 'false'); + b.setAttribute('aria-label', 'Generation ' + i); + b.innerHTML = '' + i + ''; + strip.appendChild(b); + const gb = new Goban(b.querySelector('canvas'), { coords: false, lite: true, seed: 20 + i }); + const fin = games[i][games[i].length - 1]; + gb.set(fin.cells, -2, false); + b.addEventListener('click', () => { + touch(); select(i, true); + const top = $('gen-board').getBoundingClientRect().top; + if (top < 0 || top > window.innerHeight * .6) $('gen-board').scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'center' }); + }); + }); + + // Elo chart + const host = $('elo-chart'); + let chart = null; + function drawChart() { + const W = Math.max(280, Math.round(host.getBoundingClientRect().width)), H = Math.round(clamp(W * .5, 200, 290)); + const pl = 44, pr = 12, pt = 14, pb = 28, top = 2600; + const X = g => pl + g / last * (W - pl - pr), Y = e => pt + (1 - e / top) * (H - pt - pb); + const line = gens.map((g, i) => (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(g.elo).toFixed(1)).join(' '); + const band = gens.map((g, i) => (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(g.elo + 1.96 * g.se).toFixed(1)).join(' ') + ' ' + + gens.slice().reverse().map((g, j) => 'L' + X(last - j).toFixed(1) + ' ' + Y(Math.max(0, g.elo - 1.96 * g.se)).toFixed(1)).join(' ') + ' Z'; + let grid = '', axis = ''; + for (let e = 0; e <= 2500; e += 500) { + grid += ``; + axis += `${e}`; + } + for (let g = 0; g <= last; g += (W < 480 ? 4 : 2)) axis += `${g}`; + const A = data.anchors || {}; + const anchor = (key, label, dy, right) => A[key] === undefined ? '' : + `${label} · ${Math.round(A[key])}`; + const pts = gens.map((g, i) => ``).join(''); + host.innerHTML = ` + + ${grid}${axis} + ${anchor('gnugo-l6', W < 520 ? 'GNU Go L6' : 'GNU Go level 6', -6, false)}${anchor('gnugo-l1', W < 520 ? 'GNU Go L1' : 'GNU Go level 1', 14, false)}${anchor('rollout', W < 520 ? 'rollouts' : 'Monte-Carlo rollouts', -6, true)} + + + + + ${pts} + + + `; + chart = { X, Y }; + const hit = host.querySelector('#chart-hit'); + const pick = ev => { + const r = hit.getBoundingClientRect(); + const g = Math.round(clamp((ev.clientX - r.left) / r.width, 0, 1) * last); + if (g !== cur) select(g, true); + }; + hit.addEventListener('pointerdown', ev => { touch(); pick(ev); hit.setPointerCapture(ev.pointerId); }); + hit.addEventListener('pointermove', ev => { if (ev.buttons) pick(ev); }); + placeCursor(); + } + function placeCursor() { + if (!chart || cur < 0) return; + const c = host.querySelector('#chart-cursor'), d = host.querySelector('#chart-dot'); + const x = chart.X(cur), y = chart.Y(gens[cur].elo); + c.setAttribute('x1', x); c.setAttribute('x2', x); + d.setAttribute('cx', x); d.setAttribute('cy', y); + host.querySelectorAll('.elo-pt').forEach(p => p.classList.toggle('on', +p.dataset.g <= cur)); + } + new ResizeObserver(() => { const w = Math.round(host.getBoundingClientRect().width); if (w && w !== host._w) { host._w = w; drawChart(); } }).observe(host); + + function heatOf(g) { + // bubble size ~ sqrt(probability), on a fixed scale so generations compare honestly + return g.opening.slice(0, N).map(p => Math.sqrt(clamp(p / .03, 0, 1))); + } + function caption() { + const g = gens[cur]; + if (view === 'instinct') { + const op = g.opening.slice(0, N), best = op.indexOf(Math.max(...op)); + return `Raw network policy on an empty board, before any search. Top: ${vertex(best)} ${(100 * op[best]).toFixed(1)}% (uniform = 1.2%).`; + } + if (!g.game) return 'No self-play game recorded.'; + return `Generation ${cur} vs itself · ${g.game.moves.length} moves · ${g.game.result}`; + } + function showBoard(animate) { + replayTok++; + const g = gens[cur]; + $('gen-replay').hidden = view !== 'game'; + if (view === 'instinct') { + board.set(new Int8Array(N), -2, animate, { fade: false }); + board.setHeat(heatOf(g)); + } else { + board.heat = null; board.heatFrom = null; + const fin = games[cur][games[cur].length - 1]; + board.set(fin.cells, -2, animate, { stagger: 240, fade: false }); + } + $('gen-cap').textContent = caption(); + } + function select(i, animate) { + i = clamp(i, 0, last); + if (i === cur) return; + cur = i; + const g = gens[i]; + $('gen-num').textContent = String(i); + $('gen-elo').textContent = (g.elo > 0 ? '+' : '') + Math.round(g.elo); + $('gen-se').textContent = i === 0 ? 'the baseline: random play' : '± ' + Math.round(1.96 * g.se) + ' (95%) · gen 0 = 0'; + $('gen-note').innerHTML = NOTES[i] || ''; + const chips = (g.results || []).map(r => `
  • vs ${oppName(r.opp)} ${r.w}–${r.l}
  • `); + if (i === last) chips.unshift('
  • vs GNU Go level 10 66–34 · 100 games
  • '); + if (!chips.length) chips.push('
  • no matches: every later generation is measured against it
  • '); + $('gen-results').innerHTML = chips.join(''); + range.value = String(i); + range.style.setProperty('--p', (100 * i / last) + '%'); + strip.querySelectorAll('.thumb').forEach((b, j) => b.setAttribute('aria-pressed', String(j === i))); + placeCursor(); + showBoard(animate); + } + function touch() { userTouched = true; introTok++; } + + range.addEventListener('input', () => { touch(); select(+range.value, true); }); + document.querySelectorAll('#generations .seg button').forEach(b => b.addEventListener('click', () => { + document.querySelectorAll('#generations .seg button').forEach(x => x.setAttribute('aria-pressed', String(x === b))); + view = b.dataset.view; touch(); showBoard(true); + })); + $('gen-replay').addEventListener('click', async () => { + touch(); + const my = ++replayTok, pos = games[cur]; + board.set(new Int8Array(N), -2, false); + for (let j = 1; j < pos.length; j++) { + if (my !== replayTok) return; + board.set(pos[j].cells, pos[j].last, true); + await sleep(reduceMotion ? 0 : 90); + } + board.set(pos[pos.length - 1].cells, -2, false); + }); + + // the intro: on first sight, sweep from generation 0 to the final network + if (reduceMotion) select(last, false); + else { + select(0, false); + let started = false; + const io = onVisible($('gen-board'), v => { + if (!v || started || userTouched) return; + started = true; io.disconnect(); + const my = ++introTok; + (async () => { + await sleep(500); + for (let i = 1; i <= last; i++) { + if (my !== introTok) return; + select(i, true); + await sleep(i < 4 ? 1000 : 700); + } + })(); + }, { threshold: .45 }); + } + } + + /* ============================================================ + 03 play the real engine + ============================================================ */ + function playSection() { + const board = new Goban($('play-board'), { coords: true, seed: 9 }); + const overlay = $('play-overlay'), overlayIn = $('play-overlay-in'); + const setWin = winbar($('pp-wb'), $('pp-wb-b'), $('pp-wb-w')); + const turn = $('pp-turn'), prog = $('pp-progress'), pbar = $('pp-pbar'), cands = $('pp-cands'), result = $('pp-result'); + let engine = null, phase = 'idle', human = BLACK, visits = 400, state = null, token = 0; + + function radio(group, attr, onPick) { + const btns = document.querySelectorAll(group); + btns.forEach(b => b.addEventListener('click', () => { + btns.forEach(x => x.setAttribute('aria-checked', String(x === b))); + onPick(+b.dataset[attr]); + })); + } + const NAMES = { 100: 'Casual', 400: 'Club', 1600: 'Full' }; + const hint = () => { const h = $('ov-hint'); if (h) h.textContent = `You play ${human === BLACK ? 'Black' : 'White'} · ${NAMES[visits]} strength`; }; + radio('.side', 'color', c => { human = c; hint(); }); + radio('.seg-dark button', 'visits', v => { visits = v; hint(); }); + const idle = '

    '; + overlay.addEventListener('click', ev => { if (ev.target.closest('#ov-start')) start(); }); + + function setTurn(text, color, thinking) { + turn.innerHTML = `${text}`; + turn.classList.toggle('thinking', !!thinking); + } + function show(s, animate) { + state = s; + board.set(Int8Array.from(s.cells), s.last, animate); + $('pp-move').textContent = s.moveNum; + $('pp-caps').textContent = s.capturesB + ' · ' + s.capturesW; + } + function showCands(top, done) { + if (!top || !top.length) { cands.innerHTML = '
  • Gofer\'s candidate moves appear here while it thinks.
  • '; return; } + const total = top.reduce((a, c) => a + c.visits, 0) || 1; + cands.innerHTML = top.slice(0, 5).map(c => + `
  • ${vertex(c.move)}${pct((c.q + 1) / 2)}
  • `).join(''); + board.setMarks(done ? [] : top.filter(c => c.move >= 0).slice(0, 4).map((c, i) => ({ p: c.move, label: String(Math.round(100 * c.visits / total)), w: c.visits / total, best: i === 0 }))); + } + function goferColor() { return human === BLACK ? WHITE : BLACK; } + function yourTurn(msg) { + phase = 'human'; + setTurn(msg || 'Your move', human, false); + prog.textContent = ''; pbar.style.width = '0'; + } + function finish() { + phase = 'over'; + const m = state.margin, winner = m > 0 ? BLACK : WHITE, res = (m > 0 ? 'B+' : 'W+') + Math.abs(m); + setWin(winner === BLACK ? 1 : 0); + setTurn(winner === human ? 'You win!' : 'Gofer wins', winner, false); + result.hidden = false; + result.innerHTML = (winner === human ? 'You beat Gofer by ' : 'Gofer wins by ') + Math.abs(m) + ' points' + + `${res} under area scoring: stones on the board plus surrounded empty points, komi 7.5.`; + board.setGhost(null); + prog.textContent = ''; pbar.style.width = '0'; + } + async function engineMove() { + const my = token, gc = goferColor(); + phase = 'thinking'; + setTurn('Gofer is thinking', gc, true); + board.setGhost(null); + try { + const res = await engine.genmove(visits, p => { + if (my !== token) return; + showCands(p.top, false); + prog.textContent = p.done + ' / ' + p.total; + pbar.style.width = (100 * p.done / p.total) + '%'; + const pg = (p.value + 1) / 2; + setWin(gc === BLACK ? pg : 1 - pg); + }); + if (my !== token) return; + board.setMarks([]); + show(res.state, true); + const pg = (res.value + 1) / 2; + setWin(gc === BLACK ? pg : 1 - pg); + if (res.state.gameOver) return finish(); + yourTurn(res.move === PASS ? 'Gofer passes. Your move' : 'Your move'); + } catch (err) { + if (my !== token) return; + yourTurn('Engine error: ' + err.message); + } + } + async function start() { + token++; + overlay.hidden = false; + if (!engine) { + phase = 'loading'; + $('play-start').disabled = true; + overlayIn.innerHTML = '

    Starting…

    '; + try { + engine = await window.loadGofer(msg => { const el = $('load-msg'); if (el && msg) el.textContent = msg; }); + } catch (err) { + overlayIn.textContent = ''; + overlayIn.insertAdjacentHTML('beforeend', '

    Could not start the engine.

    '); + overlayIn.querySelector('.msg').textContent = String(err.message || err); + phase = 'idle'; $('play-start').disabled = false; + return; + } + $('play-start').disabled = false; + } + overlay.hidden = true; + $('pp-setup').hidden = true; $('pp-game').hidden = false; result.hidden = true; + showCands(null); + show(engine.newGame(KOMI), false); + setWin(.5); + if (human === WHITE) engineMove(); else yourTurn(); + } + function humanMove(p) { + if (phase !== 'human') return; + const r = engine.play(p); + if (!r.ok) { + setTurn(/ko|superko|repeat/i.test(r.error) ? 'Illegal: that repeats a position (ko)' : 'Illegal move', human, false); + return; + } + show(r.state, true); + board.setGhost(null); + if (r.state.gameOver) return finish(); + engineMove(); + } + + $('play-start').addEventListener('click', start); + const canvas = $('play-board'); + canvas.addEventListener('pointermove', ev => { + if (phase !== 'human' || ev.pointerType === 'touch') return; + const p = board.pointAt(ev); + const ok = p >= 0 && state && state.legal.includes(p); + board.setGhost(ok ? { p, color: human } : null); + canvas.style.cursor = ok ? 'pointer' : 'default'; + }); + canvas.addEventListener('pointerleave', () => board.setGhost(null)); + canvas.addEventListener('click', ev => { const p = board.pointAt(ev); if (p >= 0) humanMove(p); }); + $('pp-pass').addEventListener('click', () => humanMove(PASS)); + $('pp-undo').addEventListener('click', () => { + if (!engine || !state || phase === 'thinking' || phase === 'loading' || state.moveNum === 0) return; + token++; + const n = state.toPlay === human ? 2 : 1; + result.hidden = true; + board.setMarks([]); + show(engine.undo(Math.min(n, state.moveNum)), false); + if (state.toPlay !== human) engineMove(); else yourTurn(); + }); + $('pp-new').addEventListener('click', () => { + if (phase === 'loading') return; + token++; + phase = 'idle'; + board.setMarks([]); board.setGhost(null); + $('pp-setup').hidden = false; $('pp-game').hidden = true; + overlay.hidden = false; + overlayIn.innerHTML = idle; hint(); + }); + } + + /* ============================================================ + 04 terminal + quick start + ============================================================ */ + async function terminal() { + const sessions = await fetchJSON('data/sessions.json'); + const body = $('term-body'), title = $('term-title'); + let tok = 0, started = false; + async function type(key) { + const my = ++tok, s = sessions[key]; + title.textContent = s.title; + body.textContent = ''; + for (const l of s.lines) { + if (my !== tok) return; + const span = document.createElement('span'); + span.className = 't-' + l.t; + body.appendChild(span); + if ((l.t === 'cmd' || l.t === 'in') && !reduceMotion) { + span.classList.add('caret'); + for (const ch of l.s) { if (my !== tok) return; span.textContent += ch; await sleep(15); } + span.classList.remove('caret'); + await sleep(220); + } else { + span.textContent = l.s; + if (!reduceMotion) await sleep(28); + } + body.appendChild(document.createTextNode('\n')); + body.scrollTop = body.scrollHeight; + } + } + const tabs = document.querySelectorAll('.term-tabs button'); + tabs.forEach(b => b.addEventListener('click', () => { + tabs.forEach(x => x.setAttribute('aria-selected', String(x === b))); + started = true; type(b.dataset.session); + })); + const io = onVisible(body, v => { if (v && !started) { started = true; io.disconnect(); type('play'); } }, { threshold: .3 }); + } + + function copyButton() { + const btn = $('quick-copy'); + btn.addEventListener('click', async () => { + const text = $('quick-code').innerText.split('\n').map(l => l.replace(/\s+#.*$/, '')).join('\n'); + try { await navigator.clipboard.writeText(text); } + catch (e) { + const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); } catch (e2) { /* nothing else to try */ } + ta.remove(); + } + btn.classList.add('done'); + btn.setAttribute('aria-label', 'Copied'); + setTimeout(() => { btn.classList.remove('done'); btn.setAttribute('aria-label', 'Copy commands'); }, 1600); + }); + } + + /* ============================================================ + chrome: nav border, reveals + ============================================================ */ + function chrome() { + const nav = $('nav'); + const onScroll = () => nav.classList.toggle('scrolled', window.scrollY > 8); + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + const els = document.querySelectorAll('.rv'); + if (reduceMotion || !('IntersectionObserver' in window)) { els.forEach(e => e.classList.add('in')); return; } + const io = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } }), { threshold: .12, rootMargin: '0px 0px -40px 0px' }); + els.forEach(e => io.observe(e)); + } + + /* ============================================================ + boot + ============================================================ */ + chrome(); + countUps(); + copyButton(); + playSection(); + hero().catch(err => { $('hero-cap').textContent = 'Could not load the game: ' + err.message; }); + fetchJSON('data/generations.json').then(data => { + loopSection(data.gens); + gensSection(data); + }).catch(err => { $('gen-cap').textContent = 'Could not load the generations: ' + err.message; }); + terminal().catch(() => { $('term-body').textContent = 'Could not load the captured sessions.'; }); +})();