diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ce98bc5..6b443c8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,3 +27,9 @@ jobs:
|| { echo "::error::$repo is not on the page"; exit 1; }
done
echo "the page names every repository this org has"
+ - name: the playground page is built and linked
+ run: |
+ test -f public/playground/index.html || { echo "::error::no /playground/ page"; exit 1; }
+ grep -q 'id=nd-canvas' public/playground/index.html || { echo "::error::the playground page has no canvas"; exit 1; }
+ grep -qE 'href="?/playground/"?' public/index.html || { echo "::error::the landing does not link to the playground"; exit 1; }
+ echo "the playground page is built, and the landing links to it"
diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml
index 1f2eb6e..f2692f5 100644
--- a/.github/workflows/hugo.yml
+++ b/.github/workflows/hugo.yml
@@ -53,10 +53,34 @@ jobs:
id: pages
uses: actions/configure-pages@v6
+ # The playground's two assets are build artifacts, never committed: the
+ # wasm compiled from playground/ at this commit, and wasm_exec.js taken
+ # from the SAME toolchain (the two must match). Built before Hugo, which
+ # reads the wasm's size for the loader's progress bar.
+ - name: Setup Go
+ uses: actions/setup-go@v7
+ with:
+ go-version: "1.27.1"
+ cache-dependency-path: playground/go.sum
+
+ - name: Build the playground
+ working-directory: playground
+ env:
+ GOWORK: "off"
+ GOTOOLCHAIN: local
+ run: |
+ mkdir -p ../static/playground
+ GOOS=js GOARCH=wasm go build -trimpath \
+ -ldflags "-s -w -X main.buildVersion=$(git rev-parse --short HEAD)" \
+ -o ../static/playground/playground.wasm ./cmd/playground-wasm
+ cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" ../static/playground/
+
- name: Build
env:
HUGO_ENVIRONMENT: production
HUGO_ENV: production
+ # Cache-busts the playground assets: a new deploy is a new URL.
+ HUGO_PARAMS_WASMSHA: ${{ github.sha }}
run: |
hugo \
--gc \
diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml
new file mode 100644
index 0000000..1deea6a
--- /dev/null
+++ b/.github/workflows/playground.yml
@@ -0,0 +1,131 @@
+# The playground (playground/, a nested Go module): go-ndarray compiled to
+# WebAssembly inside a go-widgets canvas app. Three gates:
+#
+# test gofmt, vet, the native suite under -race at 100% statement
+# coverage (cmd/playground-wasm is the js-only shell and is
+# excluded), the engine suite run AS WebAssembly under node
+# (where GOMAXPROCS is 1 and go-ndarray must start no helper
+# goroutine), and the wasm build, whose size is reported.
+# ui-guards mvvmlint (no direct widget-state writes) and bricolint (no
+# hand-drawn UI), each with a negative control proving it bites.
+# browser-proof the BUILT page (Hugo + the wasm) in headless Chrome, light
+# and dark forced, 1x and 2x: real clicks and keys, asserted on
+# the app's state AND on canvas pixels. A missing browser is a
+# failure here, never a skip.
+name: playground
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ GOWORK: "off"
+ GOTOOLCHAIN: local
+
+jobs:
+ test:
+ name: test, coverage, wasm
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: playground
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version: "1.27.1"
+ cache-dependency-path: playground/go.sum
+ - uses: actions/setup-node@v7
+ with:
+ node-version: "24"
+ - name: gofmt
+ run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
+ - name: vet (native and js/wasm)
+ run: go vet ./... && GOOS=js GOARCH=wasm go vet ./...
+ - name: test (-race)
+ run: go test -race -count=1 ./...
+ - name: 100% statement coverage
+ run: |
+ go test -count=1 -coverprofile=coverage.out . ./engine ./vm
+ go tool cover -func=coverage.out | tee coverage.txt | tail -1
+ bad=$(awk '$NF!="100.0%"' coverage.txt)
+ [ -z "$bad" ] || { echo "::error::below 100%:"; echo "$bad"; exit 1; }
+ - name: engine suite as WebAssembly (node)
+ run: GOOS=js GOARCH=wasm go test -count=1 -v -run 'TestHelperGoroutines|TestOperations|TestErrors|TestLayout' -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" ./engine
+ - name: wasm build and size
+ run: |
+ GOOS=js GOARCH=wasm go build -trimpath -o /tmp/playground.wasm ./cmd/playground-wasm
+ raw=$(stat -c %s /tmp/playground.wasm); gz=$(gzip -9 -c /tmp/playground.wasm | wc -c)
+ echo "playground.wasm: $raw bytes, $gz gzipped" | tee -a "$GITHUB_STEP_SUMMARY"
+
+ ui-guards:
+ name: mvvmlint + bricolint
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: playground
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version: "1.27.1"
+ cache-dependency-path: playground/go.sum
+ - name: install the guards
+ run: |
+ go install github.com/go-widgets/mvvmlint/cmd/mvvmlint@latest
+ go install github.com/go-widgets/bricolint/cmd/bricolint@v0.1.0
+ - name: MVVM only (mvvmlint)
+ run: go vet -vettool="$(go env GOPATH)/bin/mvvmlint" ./...
+ - name: no hand-drawn UI (bricolint)
+ run: go vet -vettool="$(go env GOPATH)/bin/bricolint" ./...
+ - name: negative controls (each guard must bite)
+ run: BRICOLINT="$(go env GOPATH)/bin/bricolint" MVVMLINT="$(go env GOPATH)/bin/mvvmlint" bash hack/lint-negative-controls.sh
+
+ browser-proof:
+ name: browser proof (headless Chrome)
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: playground
+ env:
+ PLAYGROUND_REQUIRE_BROWSER: "1"
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version: "1.27.1"
+ cache-dependency-path: playground/go.sum
+ - uses: actions/setup-node@v7
+ with:
+ node-version: "24"
+ - uses: peaceiris/actions-hugo@v3
+ with:
+ hugo-version: "0.167.0"
+ extended: true
+ - name: install headless Chrome
+ id: chrome
+ uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2.2.0
+ with:
+ chrome-version: stable
+ install-dependencies: true
+ - name: install puppeteer-core
+ run: npm install --no-save --prefix "${{ runner.temp }}/pptr" puppeteer-core@24.43.1
+ - name: run the proof
+ env:
+ CHROME: ${{ steps.chrome.outputs.chrome-path }}
+ PLAYGROUND_NODE_PATH: ${{ runner.temp }}/pptr/node_modules
+ PLAYGROUND_CAPTURE_DIR: ${{ runner.temp }}/captures
+ run: go test -count=1 -v -run TestBrowserProof .
+ - name: keep the screenshots
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playground-captures
+ path: ${{ runner.temp }}/captures
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index 256c50e..594d8e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,6 @@ public/
resources/
.hugo_build.lock
.DS_Store
+# Built by the pages workflow from playground/ (never committed).
+static/playground/playground.wasm
+static/playground/wasm_exec.js
diff --git a/content/playground.md b/content/playground.md
new file mode 100644
index 0000000..1a53a02
--- /dev/null
+++ b/content/playground.md
@@ -0,0 +1,4 @@
+---
+title: "Playground"
+layout: "playground"
+---
diff --git a/layouts/_default/playground.html b/layouts/_default/playground.html
new file mode 100644
index 0000000..de242c3
--- /dev/null
+++ b/layouts/_default/playground.html
@@ -0,0 +1,141 @@
+
+
+
+
+
+Playground — {{ .Site.Title }}
+
+
+{{ partial "styles.html" . }}
+
+
+
+
+
+
+
+
+
Downloading go-ndarray compiled to WebAssembly…
+
+
0%
+
+
+
+ The playground runs go-ndarray compiled to WebAssembly and needs JavaScript to start it.
+
+{{/* Both files are build artifacts: the pages workflow compiles the wasm from
+ playground/ and copies wasm_exec.js from the SAME Go toolchain, so the two
+ always match. Neither is committed. ?v= is the deployed commit, so a new
+ deploy is a new URL and a cached copy can only be the bytes it names. */}}
+{{ $sha := .Site.Params.wasmsha }}
+{{ $wasm := "/playground/playground.wasm" }}
+{{ $exec := "/playground/wasm_exec.js" }}
+{{ with $sha }}{{ $wasm = printf "%s?v=%s" $wasm . }}{{ $exec = printf "%s?v=%s" $exec . }}{{ end }}
+{{ $bytes := 0 }}{{ with (os.Stat "static/playground/playground.wasm") }}{{ $bytes = .Size }}{{ end }}
+
+
+
+
+
diff --git a/layouts/index.html b/layouts/index.html
index 21a33f5..1bbd913 100644
--- a/layouts/index.html
+++ b/layouts/index.html
@@ -11,7 +11,7 @@
diff --git a/playground/README.md b/playground/README.md
new file mode 100644
index 0000000..edef08a
--- /dev/null
+++ b/playground/README.md
@@ -0,0 +1,72 @@
+# go-ndarray playground
+
+The page at : go-ndarray itself,
+compiled to WebAssembly, inside a [go-widgets](https://github.com/go-widgets)
+canvas application. Nothing is computed in JavaScript — the page only loads the
+wasm, forwards input and follows the landing's theme toggle.
+
+A visitor builds a pipeline of steps — create an array (`Arange`, `Linspace`,
+`Zeros`/`Ones`/`Full`, `Eye`, typed-in `FromData`), then apply views
+(`Reshape` with `-1`, `Transpose`, NumPy-style `Slice`), broadcasting
+arithmetic, ufuncs, reductions along an axis, `Concatenate`/`Stack`,
+`MatMul`/`Dot`/`Outer` — and sees for each step its shape, its strides (in
+elements and in bytes), whether it is a view sharing memory with earlier steps,
+the time one call took in the browser, its values as a heatmap table, and the
+whole pipeline as a Go program that compiles and its NumPy equivalent (both
+copyable). Errors are go-ndarray's own (`ndarray: shapes are not broadcastable:
+[2 3] vs [3 2]`).
+
+## Layout
+
+| package | role |
+|---|---|
+| `engine` | the model: the operation catalog, argument parsing, running a step (timed), its Go and NumPy spellings, and the layout of a result read from go-ndarray's `Array` |
+| `vm` | the ViewModel: every piece of state as a `go-widgets/mvvm` Observable, ObservableList or Command; no widget |
+| `.` (`playground`) | the View: toolkit widgets in box layouts, bound to the ViewModel (`mvvmtk`, plus `view_binding.go`); event routing through the root box |
+| `cmd/playground-wasm` | the browser shell: canvas at device pixels, input forwarding, `ndarraySetTheme`, `ndarrayDebug`/`ndarrayRects` for the proofs |
+
+Widgets used: `Label`, `DropDown`, `ListBox`, `Entry`, `Button`,
+`ViewSwitcher`, `TextView`, `Table`, `VBox`/`HBox`. The heatmap is
+`Table.CellFill` with `toolkit.Viridis`, added to go-widgets/toolkit for this
+page.
+
+Strides are not exported by go-ndarray v0.7.1; `engine.LayoutOf` reads the
+`strides`, `offset` and `data` fields through `reflect` (read-only, no
+`unsafe`), and `TestLayoutReadsTheLibrary` fails if a release renames them.
+
+## go-ndarray's helper goroutines under WebAssembly
+
+go-ndarray keeps up to `GOMAXPROCS-1` helper goroutines alive for large
+operations (its README, "Goroutines"). Under `GOOS=js` the Go runtime has a
+single P: `GOMAXPROCS` is 1, every operation takes go-ndarray's serial path, and
+**no helper goroutine is ever started**. `engine/goroutines_test.go` checks it
+both ways: run as WebAssembly under node, the goroutine count is unchanged after
+10⁶-element `Exp`, `Sum` and a 1000² `MatMul`; run natively with
+`GOMAXPROCS=4`, the same steps start three helpers (the positive control). The
+browser proof repeats the WebAssembly check in Chrome. The timings the page
+shows are therefore single-threaded, and without SIMD (wasm has no go-asmgen
+kernel), so they are slower than native go-ndarray.
+
+## Build and test
+
+```sh
+GOWORK=off go test -race ./... # 100% statement coverage in CI
+GOWORK=off GOOS=js GOARCH=wasm go test \
+ -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" ./engine
+GOOS=js GOARCH=wasm go build -o ../static/playground/playground.wasm ./cmd/playground-wasm
+cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" ../static/playground/
+```
+
+The wasm is about 13 MB (3.4 MB gzipped, as Pages serves it). Neither it nor
+`wasm_exec.js` is committed: the deploy builds both from this commit.
+
+The browser proof (`browser_test.go` + `browsertest/driver.cjs`) builds the
+site with Hugo and drives it in headless Chrome:
+
+```sh
+CHROME=… PLAYGROUND_NODE_PATH= HUGO=hugo \
+ GOWORK=off go test -run TestBrowserProof -v .
+```
+
+Screenshots go to `$PLAYGROUND_CAPTURE_DIR`, else the user config directory —
+never into a repository (the helper refuses a directory inside a git tree).
diff --git a/playground/app.go b/playground/app.go
new file mode 100644
index 0000000..5c79b9b
--- /dev/null
+++ b/playground/app.go
@@ -0,0 +1,377 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Package playground is the View of the go-ndarray playground: a go-widgets
+// canvas application compiled to WebAssembly. It builds toolkit widgets, lays
+// them out in boxes and binds each one to the ViewModel (package vm) — it
+// draws nothing by hand and holds no state of its own beyond layout and
+// pointer capture. cmd/playground-wasm is the browser shell around it.
+package playground
+
+import (
+ "github.com/go-ndarray/go-ndarray.github.io/playground/vm"
+ "github.com/go-widgets/mvvmtk"
+ "github.com/go-widgets/painter"
+ "github.com/go-widgets/toolkit"
+)
+
+// Logical sizes, before the device scale (toolkit.Scaled) is applied.
+const (
+ margin = 12
+ gap = 8
+ barH = 34
+ rowH = 30
+ labelH = 22
+ leftW = 380
+ codeH = 220
+ narrowW = 860 // below this the two columns stack
+ stackedTop = 420 // height of the form when stacked
+ cellW = 76
+ rowLabelW = 44
+)
+
+// baseFontPx is the body text size in logical pixels.
+const baseFontPx = 14
+
+// SetupText selects the toolkit's anti-aliased text at the device scale, so
+// one logical pixel is one CSS pixel and text is crisp on a HiDPI screen. It
+// must run before the first layout measures text.
+func SetupText(scale float64) {
+ if scale <= 0 {
+ scale = 1
+ }
+ toolkit.SetMetricScale(scale)
+ // Logical pixels: the toolkit renders the face at size × MetricScale
+ // itself (and re-renders it when the scale changes).
+ _ = toolkit.UseOpenTypeTextSize(baseFontPx)
+}
+
+// State is the View: the widgets, their layout, the ViewModel they are bound
+// to, and the pointer capture.
+type State struct {
+ w, h int
+ dark bool
+ theme *toolkit.Theme
+ VM *vm.ViewModel
+
+ // top bar
+ title *toolkit.Label
+ presets *toolkit.DropDown
+ reset *toolkit.Button
+
+ // left: the pipeline and the form
+ pipeLbl *toolkit.Label
+ steps *toolkit.ListBox
+ opLbl *toolkit.Label
+ ops *toolkit.DropDown
+ inLbl *toolkit.Label
+ inputs *toolkit.DropDown
+ hint *toolkit.Label
+ args *toolkit.Entry
+ apply *toolkit.Button
+ undo *toolkit.Button
+ errLabel *toolkit.Label
+
+ // right: the selected step
+ stTitle *toolkit.Label
+ shape *toolkit.Label
+ memory *toolkit.Label
+ timing *toolkit.Label
+ goLine *toolkit.Label
+ pyLine *toolkit.Label
+ note *toolkit.Label
+ table *toolkit.Table
+ lang *toolkit.ViewSwitcher
+ copyB *toolkit.Button
+ code *toolkit.TextView
+
+ status *toolkit.Label
+
+ // The box tree. The boxes are the toolkit's own types, not wrappers: the
+ // focus system walks them to find the widget that receives the keyboard.
+ root, left, right *toolkit.VBox
+ body toolkit.Widget // an HBox, or a VBox when narrow
+ formBtns, codeBar, top *toolkit.HBox
+
+ dropdowns []*toolkit.DropDown
+
+ // Pointer capture: the widget a press landed on gets the drag and the
+ // release, in its own coordinates, even when the pointer leaves it.
+ dragTarget toolkit.Widget
+
+ dirty bool
+ unbind []func()
+}
+
+// vbox makes a VBox with the given spacing.
+func vbox(spacing int) *toolkit.VBox {
+ b := toolkit.NewVBox()
+ b.Spacing = spacing
+ return b
+}
+
+// hbox makes an HBox with the given spacing.
+func hbox(spacing int) *toolkit.HBox {
+ b := toolkit.NewHBox()
+ b.Spacing = spacing
+ return b
+}
+
+// NewState builds the View over a fresh ViewModel, w×h device pixels.
+func NewState(w, h int, dark bool) *State {
+ s := &State{VM: vm.New()}
+ s.dark = dark
+ s.theme = Theme(dark)
+ inv := func() { s.dirty = true }
+ v := s.VM
+
+ s.title = toolkit.NewLabel("go-ndarray playground")
+ s.presets = toolkit.NewDropDown(v.PresetNames, 0)
+ s.reset = toolkit.NewButton("Clear", nil)
+
+ s.pipeLbl = toolkit.NewLabel("Pipeline — click a step to inspect it")
+ s.steps = toolkit.NewListBox(nil)
+ s.opLbl = toolkit.NewLabel("Operation")
+ s.ops = toolkit.NewDropDown(v.OpNames, 0)
+ s.inLbl = toolkit.NewLabel("Input")
+ s.inputs = toolkit.NewDropDown(nil, 0)
+ s.hint = toolkit.NewLabel("")
+ s.args = toolkit.NewEntry("")
+ s.apply = toolkit.NewButton("Apply", nil)
+ s.undo = toolkit.NewButton("Remove last", nil)
+ s.errLabel = toolkit.NewLabel("")
+
+ s.stTitle = toolkit.NewLabel("")
+ s.shape = toolkit.NewLabel("")
+ s.memory = toolkit.NewLabel("")
+ s.timing = toolkit.NewLabel("")
+ s.goLine = toolkit.NewLabel("")
+ s.pyLine = toolkit.NewLabel("")
+ s.note = toolkit.NewLabel("")
+ s.table = toolkit.NewTable(nil, nil)
+ s.lang = toolkit.NewViewSwitcher([]string{"Go program", "NumPy equivalent"}, 0)
+ s.copyB = toolkit.NewButton("Copy", nil)
+ s.code = toolkit.NewTextView("")
+ s.code.ShowLineNumbers = true
+
+ s.status = toolkit.NewLabel("")
+
+ for _, l := range []*toolkit.Label{s.hint, s.errLabel, s.memory, s.shape, s.timing, s.goLine, s.pyLine, s.note, s.status} {
+ l.Ellipsis = true
+ }
+
+ // Styles first: BindCommand remembers a button's resting style to restore
+ // it when the command becomes executable again.
+ s.apply.Style = toolkit.ButtonProminent
+ s.undo.Style = toolkit.ButtonSecondary
+ s.reset.Style = toolkit.ButtonSecondary
+ s.copyB.Style = toolkit.ButtonSecondary
+ s.args.Placeholder = "arguments"
+ s.args.OnSubmit = func(string) { v.Apply.Execute() }
+
+ s.unbind = append(s.unbind,
+ mvvmtk.BindSelectedIndex(s.presets, v.PresetIndex, inv),
+ mvvmtk.BindCommand(s.reset, v.Reset, inv),
+ mvvmtk.BindListItems(s.steps, v.Steps, func(r string) string { return r }, inv),
+ mvvmtk.BindListSelection(s.steps, v.Selected, inv),
+ mvvmtk.BindSelectedIndex(s.ops, v.OpIndex, inv),
+ mvvmtk.BindDropDownOptions(s.inputs, v.Inputs, func(n string) string { return n }, inv),
+ mvvmtk.BindSelectedIndex(s.inputs, v.InputIndex, inv),
+ mvvmtk.BindLabel(s.hint, v.Hint, inv),
+ bindEntry(s.args, v.Args, inv),
+ mvvmtk.BindCommand(s.apply, v.Apply, inv),
+ mvvmtk.BindCommand(s.undo, v.RemoveLast, inv),
+ mvvmtk.BindLabel(s.errLabel, v.Error, inv),
+ mvvmtk.BindLabel(s.stTitle, v.Title, inv),
+ mvvmtk.BindLabel(s.shape, v.Shape, inv),
+ mvvmtk.BindLabel(s.memory, v.Memory, inv),
+ mvvmtk.BindLabel(s.timing, v.Timing, inv),
+ mvvmtk.BindLabel(s.goLine, v.GoLine, inv),
+ mvvmtk.BindLabel(s.pyLine, v.PyLine, inv),
+ mvvmtk.BindLabel(s.note, v.Note, inv),
+ bindGrid(s.table, v.Grid, inv),
+ mvvmtk.BindViewSwitcher(s.lang, v.CodeLang, inv),
+ mvvmtk.BindCommand(s.copyB, v.CopyCode, inv),
+ mvvmtk.BindTextView(s.code, v.Code, inv),
+ mvvmtk.BindLabel(s.status, v.Status, inv),
+ )
+ s.dropdowns = []*toolkit.DropDown{s.presets, s.ops, s.inputs}
+ s.applyTheme()
+ s.Resize(w, h)
+ return s
+}
+
+// layout (re)builds the box tree for the current width: two columns side by
+// side, or stacked on a narrow screen.
+func (s *State) layout() {
+ sc := toolkit.Scaled
+ narrow := s.w < sc(narrowW)
+
+ s.top = hbox(sc(gap))
+ s.top.AddFlex(s.title, 1)
+ s.top.AddFixed(s.presets, sc(260))
+ s.top.AddFixed(s.reset, sc(80))
+
+ s.formBtns = hbox(sc(gap))
+ s.formBtns.AddFlex(s.apply, 1)
+ s.formBtns.AddFlex(s.undo, 1)
+
+ s.left = vbox(sc(gap / 2))
+ s.left.AddFixed(s.pipeLbl, sc(labelH))
+ s.left.AddFlex(s.steps, 1)
+ s.left.AddFixed(s.opLbl, sc(labelH))
+ s.left.AddFixed(s.ops, sc(rowH))
+ s.left.AddFixed(s.inLbl, sc(labelH))
+ s.left.AddFixed(s.inputs, sc(rowH))
+ s.left.AddFixed(s.hint, sc(labelH))
+ s.left.AddFixed(s.args, sc(rowH))
+ s.left.AddFixed(s.formBtns, sc(rowH+2))
+ s.left.AddFixed(s.errLabel, sc(labelH))
+
+ s.codeBar = hbox(sc(gap))
+ s.codeBar.AddFlex(s.lang, 1)
+ s.codeBar.AddFixed(s.copyB, sc(90))
+
+ s.right = vbox(sc(2))
+ s.right.AddFixed(s.stTitle, sc(labelH+4))
+ s.right.AddFixed(s.shape, sc(labelH))
+ s.right.AddFixed(s.memory, sc(labelH))
+ s.right.AddFixed(s.timing, sc(labelH))
+ s.right.AddFixed(s.goLine, sc(labelH))
+ s.right.AddFixed(s.pyLine, sc(labelH))
+ s.right.AddFixed(s.note, sc(labelH))
+ s.right.AddFlex(s.table, 1)
+ s.right.AddFixed(s.codeBar, sc(rowH+2))
+ s.right.AddFixed(s.code, sc(codeH))
+
+ if narrow {
+ b := vbox(sc(gap * 2))
+ b.AddFixed(s.left, sc(stackedTop))
+ b.AddFlex(s.right, 1)
+ s.body = b
+ } else {
+ b := hbox(sc(gap * 2))
+ b.AddFixed(s.left, sc(leftW))
+ b.AddFlex(s.right, 1)
+ s.body = b
+ }
+ s.root = vbox(sc(gap))
+ s.root.AddFixed(s.top, sc(barH))
+ s.root.AddFlex(s.body, 1)
+ s.root.AddFixed(s.status, sc(labelH))
+ m := sc(margin)
+ s.root.SetBounds(toolkit.Rect{X: m, Y: m, W: s.w - 2*m, H: s.h - 2*m})
+ s.dirty = true
+}
+
+// Resize lays the scene out for w×h device pixels.
+func (s *State) Resize(w, h int) {
+ s.w, s.h = max(w, 1), max(h, 1)
+ s.layout()
+}
+
+// Size is the scene size in device pixels.
+func (s *State) Size() (int, int) { return s.w, s.h }
+
+// Dark reports whether the dark palette is active.
+func (s *State) Dark() bool { return s.dark }
+
+// SetTheme switches between the light and the dark palette.
+func (s *State) SetTheme(dark bool) {
+ s.dark = dark
+ s.theme = Theme(dark)
+ s.applyTheme()
+ s.dirty = true
+}
+
+// applyTheme sets the ink of the labels that are not plain body text.
+func (s *State) applyTheme() {
+ muted := mutedInk(s.dark)
+ s.title.Ink = brandText(s.dark)
+ s.title.FontSize = toolkit.Scaled(19)
+ s.stTitle.Ink = brandText(s.dark)
+ for _, l := range []*toolkit.Label{s.hint, s.memory, s.timing, s.note, s.status, s.pipeLbl, s.opLbl, s.inLbl} {
+ l.Ink = muted
+ }
+ s.errLabel.Ink = errorInk(s.dark)
+}
+
+// Draw paints the whole scene into buf (w×h RGBA). Open popovers are drawn
+// last so they float above the widgets under them.
+func (s *State) Draw(buf []byte) {
+ p := painter.NewPixelPainter(buf, s.w, s.h)
+ fillBackground(buf, s.theme.Background)
+ s.root.Draw(p, s.theme)
+ for _, d := range s.dropdowns {
+ d.DrawPopover(p, s.theme)
+ }
+ s.dirty = false
+}
+
+// Dirty reports whether something changed since the last Draw.
+func (s *State) Dirty() bool { return s.dirty }
+
+// Theme returns the playground palette: the toolkit defaults with the
+// landing's neutral backgrounds and its emerald accent (go-ndarray.github.io's
+// [params.brand]: base #047857 in light, bright #34d399 in dark).
+func Theme(dark bool) *toolkit.Theme {
+ if dark {
+ t := toolkit.DefaultDark()
+ t.Background = toolkit.RGB(0x0b, 0x0e, 0x14)
+ t.Surface = toolkit.RGB(0x14, 0x1a, 0x24)
+ t.SurfaceAlt = toolkit.RGB(0x1c, 0x23, 0x31)
+ t.Border = toolkit.RGB(0x2a, 0x33, 0x42)
+ t.OnBackground = toolkit.RGB(0xe6, 0xed, 0xf3)
+ t.OnSurface = toolkit.RGB(0xe6, 0xed, 0xf3)
+ // Fills keep the base emerald, which carries white text in both
+ // themes (5.5:1); text drawn IN the accent uses brandText instead.
+ t.Accent = toolkit.RGB(0x04, 0x78, 0x57)
+ white := toolkit.RGB(0xff, 0xff, 0xff)
+ t.Extra = map[string]toolkit.RGBA{"OnAccent": white, "accent_fg_color": white}
+ return t
+ }
+ t := toolkit.DefaultLight()
+ t.Background = toolkit.RGB(0xff, 0xff, 0xff)
+ t.Surface = toolkit.RGB(0xf7, 0xf8, 0xfb)
+ t.SurfaceAlt = toolkit.RGB(0xe7, 0xe9, 0xee)
+ t.Border = toolkit.RGB(0xe0, 0xe3, 0xea)
+ t.OnBackground = toolkit.RGB(0x0f, 0x11, 0x15)
+ t.OnSurface = toolkit.RGB(0x0f, 0x11, 0x15)
+ t.Accent = toolkit.RGB(0x04, 0x78, 0x57)
+ white := toolkit.RGB(0xff, 0xff, 0xff)
+ t.Extra = map[string]toolkit.RGBA{"OnAccent": white, "accent_fg_color": white}
+ return t
+}
+
+// brandText is the accent as a text colour: the landing's --accent, base
+// emerald on white, bright emerald on the dark background (10:1).
+func brandText(dark bool) toolkit.RGBA {
+ if dark {
+ return toolkit.RGB(0x34, 0xd3, 0x99)
+ }
+ return toolkit.RGB(0x04, 0x78, 0x57)
+}
+
+// mutedInk is the landing's --slate.
+func mutedInk(dark bool) toolkit.RGBA {
+ if dark {
+ return toolkit.RGB(0x9a, 0xa4, 0xb2)
+ }
+ return toolkit.RGB(0x47, 0x55, 0x69)
+}
+
+// errorInk is a red that reads on the background of each theme.
+func errorInk(dark bool) toolkit.RGBA {
+ if dark {
+ return toolkit.RGB(0xf7, 0x8b, 0x8b)
+ }
+ return toolkit.RGB(0xb4, 0x23, 0x18)
+}
+
+// fillBackground clears the frame to the theme background before the widgets
+// paint over it (the app-template's fillBG): a buffer clear, not drawing.
+func fillBackground(buf []byte, c toolkit.RGBA) {
+ for i := 0; i+3 < len(buf); i += 4 {
+ buf[i], buf[i+1], buf[i+2], buf[i+3] = c.R, c.G, c.B, c.A
+ }
+}
diff --git a/playground/app_test.go b/playground/app_test.go
new file mode 100644
index 0000000..7ef6da5
--- /dev/null
+++ b/playground/app_test.go
@@ -0,0 +1,310 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package playground
+
+import (
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/go-ndarray/go-ndarray.github.io/playground/engine"
+ "github.com/go-widgets/mvvm"
+ "github.com/go-widgets/toolkit"
+)
+
+const testW, testH = 1280, 800
+
+func newTestState(t *testing.T, dark bool) *State {
+ t.Helper()
+ SetupText(1)
+ return NewState(testW, testH, dark)
+}
+
+func px(buf []byte, w, x, y int) toolkit.RGBA {
+ o := (y*w + x) * 4
+ return toolkit.RGBA{R: buf[o], G: buf[o+1], B: buf[o+2], A: buf[o+3]}
+}
+
+// countIn counts the pixels of r equal to c.
+func countIn(buf []byte, w int, r toolkit.Rect, c toolkit.RGBA) int {
+ n := 0
+ for y := r.Y; y < r.Y+r.H; y++ {
+ for x := r.X; x < r.X+r.W; x++ {
+ if px(buf, w, x, y) == c {
+ n++
+ }
+ }
+ }
+ return n
+}
+
+func center(r toolkit.Rect) (int, int) { return r.X + r.W/2, r.Y + r.H/2 }
+
+// The frame, in both themes: the theme background, the heatmap cells in the
+// viridis colours, and the accent on the selected step.
+func TestDrawBothThemes(t *testing.T) {
+ for _, dark := range []bool{false, true} {
+ s := newTestState(t, dark)
+ buf := make([]byte, testW*testH*4)
+ s.Draw(buf)
+ if s.Dirty() {
+ t.Error("Draw must clear dirty")
+ }
+ if got := px(buf, testW, 1, 1); got != s.theme.Background {
+ t.Errorf("dark=%v: corner %v, want the background %v", dark, got, s.theme.Background)
+ }
+ // g = b + c is [[10 21 32] [13 24 35]]: 10 is the viridis start, 35 its end.
+ tb := s.table.Bounds()
+ if n := countIn(buf, testW, tb, toolkit.Viridis(0)) + countIn(buf, testW, tb, toolkit.Viridis(1)); n < 500 {
+ t.Errorf("dark=%v: %d viridis-end pixels in the table, want a heatmap", dark, n)
+ }
+ if n := countIn(buf, testW, s.steps.Bounds(), s.theme.Accent); n < 500 {
+ t.Errorf("dark=%v: the selected step has no accent band (%d px)", dark, n)
+ }
+ if dark != s.Dark() {
+ t.Error("Dark()")
+ }
+ }
+}
+
+// A visitor's sequence, through the same entry points the browser shell
+// calls: pick an operation in the popover, edit the arguments, press Enter,
+// read the error; click a step; switch the code language; scroll.
+func TestInteractionSequence(t *testing.T) {
+ s := newTestState(t, false)
+ v := s.VM
+ buf := make([]byte, testW*testH*4)
+
+ // Click step b in the pipeline list (rows from the top of the list).
+ lb := s.steps.Bounds()
+ for y := lb.Y + 2; y < lb.Y+lb.H && v.Selected.Get() != 1; y += 3 {
+ s.HandleClick(lb.X+20, y)
+ }
+ if v.Selected.Get() != 1 || s.Debug().Input != "b" {
+ t.Fatalf("clicking the list selected %d, input %q", v.Selected.Get(), s.Debug().Input)
+ }
+
+ // Open the operation menu and click Transpose (index 8, in the first window).
+ s.HandleClick(center(s.ops.Bounds()))
+ if !s.ops.PopoverOpen() {
+ t.Fatal("the operation menu did not open")
+ }
+ s.Draw(buf) // the popover paints over the scene
+ pb := s.ops.PopoverBounds()
+ rowH := pb.H / toolkit.PopoverMaxRows
+ idx := 8
+ s.HandleClick(pb.X+pb.W/2, pb.Y+idx*rowH+rowH/2)
+ if v.OpNames[v.OpIndex.Get()] != "Transpose" || s.ops.PopoverOpen() {
+ t.Fatalf("op %s, open %v", v.OpNames[v.OpIndex.Get()], s.ops.PopoverOpen())
+ }
+ s.HandleClick(center(s.apply.Bounds()))
+ if v.Steps.Len() != 8 {
+ t.Fatalf("Apply: %d steps", v.Steps.Len())
+ }
+
+ // Pick Reshape, type a bad shape, press Enter.
+ s.HandleClick(center(s.ops.Bounds()))
+ s.HandleClick(pb.X+pb.W/2, pb.Y+7*rowH+rowH/2)
+ s.HandleClick(center(s.args.Bounds()))
+ s.HandleKeyDown("End")
+ for range 10 {
+ s.HandleKeyDown("Backspace")
+ }
+ for _, ch := range "4, 4" {
+ s.HandleChar(string(ch))
+ }
+ if v.Args.Get() != "4, 4" {
+ t.Fatalf("typed args %q", v.Args.Get())
+ }
+ s.HandleKeyDown("Enter")
+ if !strings.HasPrefix(v.Error.Get(), "ndarray: shape mismatch") {
+ t.Fatalf("error %q", v.Error.Get())
+ }
+ s.Draw(buf)
+ if n := countIn(buf, testW, s.errLabel.Bounds(), errorInk(false)); n < 10 {
+ t.Errorf("the error is not on screen in red (%d px)", n)
+ }
+
+ // The NumPy half of the language switch.
+ lr := s.lang.Bounds()
+ s.HandleClick(lr.X+lr.W*3/4, lr.Y+lr.H/2)
+ if !strings.HasPrefix(v.Code.Get(), "import numpy") {
+ t.Errorf("code %q", v.Code.Get()[:20])
+ }
+
+ // Escape closes an open popover; a click outside closes it too.
+ s.HandleClick(center(s.presets.Bounds()))
+ if !s.presets.PopoverOpen() {
+ t.Fatal("presets did not open")
+ }
+ pp := s.presets.PopoverBounds()
+ if !s.HandleScroll(pp.X+5, pp.Y+5, 0, 1) {
+ t.Error("scroll over a popover")
+ }
+ s.HandleKeyDown("Escape")
+ if s.presets.PopoverOpen() {
+ t.Error("Escape must close the popover")
+ }
+
+ // Scroll the code view and drag/release over the table.
+ cb := s.code.Bounds()
+ s.HandleScroll(cb.X+10, cb.Y+10, 0, 3)
+ if s.code.ScrollLine().Get() == 0 {
+ t.Error("the wheel did not scroll the code")
+ }
+ tb := s.table.Bounds()
+ s.HandleClick(tb.X+5, tb.Y+5)
+ if !s.HandleMove(tb.X+6, tb.Y+30) || !s.HandleRelease(tb.X+6, tb.Y+30) {
+ t.Error("a press on the table captures the drag")
+ }
+ if s.HandleRelease(0, 0) {
+ t.Error("a release with nothing captured changes nothing")
+ }
+ s.HandleClick(1, 1) // empty space: no capture
+ s.HandleMove(2, 2)
+}
+
+func TestResizeStacksWhenNarrow(t *testing.T) {
+ s := newTestState(t, false)
+ if s.left.Bounds().Y != s.right.Bounds().Y {
+ t.Error("wide: the two columns sit side by side")
+ }
+ s.Resize(600, 1000)
+ if w, h := s.Size(); w != 600 || h != 1000 {
+ t.Error("Size")
+ }
+ if s.right.Bounds().Y <= s.left.Bounds().Y {
+ t.Error("narrow: the columns stack")
+ }
+ buf := make([]byte, 600*1000*4)
+ s.Draw(buf)
+ s.Resize(0, 0)
+ if w, h := s.Size(); w != 1 || h != 1 {
+ t.Error("Resize clamps to 1x1")
+ }
+}
+
+func TestSetTheme(t *testing.T) {
+ s := newTestState(t, false)
+ s.SetTheme(true)
+ if !s.Dark() || s.theme.Background != toolkit.RGB(0x0b, 0x0e, 0x14) || s.title.Ink != brandText(true) {
+ t.Error("SetTheme(true)")
+ }
+ if Theme(false).Accent != toolkit.RGB(0x04, 0x78, 0x57) || Theme(true).Accent != toolkit.RGB(0x04, 0x78, 0x57) {
+ t.Error("the accent is the brand emerald in both themes")
+ }
+ SetupText(0) // a non-positive scale falls back to 1
+ if toolkit.MetricScale() != 1 {
+ t.Error("SetupText(0)")
+ }
+}
+
+func TestBindings(t *testing.T) {
+ obs := mvvm.NewObservable("abc")
+ e := toolkit.NewEntry("")
+ n := 0
+ un := bindEntry(e, obs, func() { n++ })
+ if e.Text().Get() != "abc" {
+ t.Error("seed")
+ }
+ obs.Set("x")
+ if e.Text().Get() != "x" || n == 0 {
+ t.Error("VM to entry")
+ }
+ e.Text().Set("y")
+ if obs.Get() != "y" {
+ t.Error("entry to VM")
+ }
+ un()
+
+ g := mvvm.NewObservableEq[*engine.Grid](nil, nil)
+ tb := toolkit.NewTable(nil, nil)
+ un = bindGrid(tb, g, func() {})
+ if tb.Columns != nil || tb.CellFill != nil {
+ t.Error("nil grid is an empty table")
+ }
+ g.Set(&engine.Grid{Values: [][]float64{{1, math.NaN(), 3}}, RowLabels: []string{"0"}, ColLabels: []string{"0", "1", "2"}, Min: 1, Max: 3})
+ if len(tb.Columns) != 4 || tb.Rows[0][2] != "NaN" {
+ t.Errorf("columns %d row %v", len(tb.Columns), tb.Rows)
+ }
+ if _, ok := tb.CellFill(0, 0); ok {
+ t.Error("the row-label column is not filled")
+ }
+ if _, ok := tb.CellFill(0, 2); ok {
+ t.Error("NaN is not filled")
+ }
+ if _, ok := tb.CellFill(5, 1); ok {
+ t.Error("out of range is not filled")
+ }
+ if c, ok := tb.CellFill(0, 3); !ok || c != toolkit.Viridis(1) {
+ t.Error("the max is the top of the scale")
+ }
+ g.Set(&engine.Grid{Values: [][]float64{{7}}, RowLabels: []string{"0"}, ColLabels: []string{"0"}, Min: 7, Max: 7})
+ if c, _ := tb.CellFill(0, 1); c != toolkit.Viridis(0.5) {
+ t.Error("a constant array sits mid-scale")
+ }
+ g.Set(&engine.Grid{})
+ if tb.Rows != nil {
+ t.Error("an empty grid clears the table")
+ }
+ un()
+}
+
+func TestDebugAndRects(t *testing.T) {
+ s := newTestState(t, true)
+ d := s.Debug()
+ if len(d.Steps) != 7 || d.Input != "g" || d.Rows != 2 || d.Cols != 4 || !d.Dark || d.MaxProcs < 1 || len(d.Ops) == 0 {
+ t.Errorf("%+v", d)
+ }
+ s.VM.InputIndex.Set(99)
+ if s.Debug().Input != "" {
+ t.Error("no input")
+ }
+ r := s.Rects()
+ if r["apply"].W == 0 || r["table"].H == 0 || len(r) != 15 {
+ t.Errorf("rects %v", r)
+ }
+}
+
+// The capture directory refuses a git work tree and accepts a plain one.
+func TestCaptureDir(t *testing.T) {
+ tree := t.TempDir()
+ if err := os.Mkdir(filepath.Join(tree, ".git"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ inside := filepath.Join(tree, "sub", "captures")
+ if got := gitTreeOf(inside); got != tree {
+ t.Fatalf("gitTreeOf(%s) = %q, want %q", inside, got, tree)
+ }
+ outside := t.TempDir()
+ if got := gitTreeOf(outside); got != "" {
+ t.Skipf("the temp dir itself sits in a git tree (%s)", got)
+ }
+ t.Setenv("PLAYGROUND_CAPTURE_DIR", outside)
+ if captureDir(t) != outside {
+ t.Error("captureDir must honour the variable")
+ }
+ buf := make([]byte, 4*4*4)
+ savePNG(t, "probe.png", buf, 4, 4)
+ if _, err := os.Stat(filepath.Join(outside, "probe.png")); err != nil {
+ t.Error(err)
+ }
+}
+
+// With PLAYGROUND_LOOK=1, write the opening frame in both themes for a
+// person to look at (outside every repository).
+func TestLookCapture(t *testing.T) {
+ if os.Getenv("PLAYGROUND_LOOK") == "" {
+ t.Skip("set PLAYGROUND_LOOK=1 to write captures")
+ }
+ for _, dark := range []bool{false, true} {
+ s := newTestState(t, dark)
+ buf := make([]byte, testW*testH*4)
+ s.Draw(buf)
+ name := map[bool]string{false: "light.png", true: "dark.png"}[dark]
+ savePNG(t, name, buf, testW, testH)
+ }
+}
diff --git a/playground/browser_test.go b/playground/browser_test.go
new file mode 100644
index 0000000..414487c
--- /dev/null
+++ b/playground/browser_test.go
@@ -0,0 +1,116 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !js
+
+package playground
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// TestBrowserProof builds the page exactly as the deploy does — the wasm from
+// this module, wasm_exec.js from the same toolchain, then Hugo — serves it,
+// and runs browsertest/driver.cjs against it in headless Chrome, light and
+// dark with the scheme forced, at 1x and 2x. See the driver for what it
+// checks. It skips when Chrome, node, puppeteer-core or Hugo is missing,
+// unless PLAYGROUND_REQUIRE_BROWSER=1 (CI), where a skip would be a pass that
+// proved nothing.
+//
+// Environment: CHROME (the browser), PLAYGROUND_NODE_PATH (a node_modules with
+// puppeteer-core), HUGO (the hugo command, default "hugo").
+func TestBrowserProof(t *testing.T) {
+ need := func(what string, ok bool) {
+ if ok {
+ return
+ }
+ if os.Getenv("PLAYGROUND_REQUIRE_BROWSER") == "1" {
+ t.Fatalf("browser proof required but %s is missing", what)
+ }
+ t.Skipf("%s is missing; set PLAYGROUND_REQUIRE_BROWSER=1 to make this a failure", what)
+ }
+ chrome := os.Getenv("CHROME")
+ if chrome == "" && runtime.GOOS == "darwin" {
+ chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+ }
+ _, err := os.Stat(chrome)
+ need("Chrome (CHROME)", chrome != "" && err == nil)
+ node, err := exec.LookPath("node")
+ need("node", err == nil)
+ nodePath := os.Getenv("PLAYGROUND_NODE_PATH")
+ _, err = os.Stat(filepath.Join(nodePath, "puppeteer-core"))
+ need("puppeteer-core (PLAYGROUND_NODE_PATH)", nodePath != "" && err == nil)
+ hugo := os.Getenv("HUGO")
+ if hugo == "" {
+ hugo = "hugo"
+ }
+ hugoArgv := strings.Fields(hugo)
+ _, err = exec.LookPath(hugoArgv[0])
+ need("Hugo (HUGO)", err == nil)
+
+ site, err := filepath.Abs("..")
+ if err != nil {
+ t.Fatal(err)
+ }
+ static := filepath.Join(site, "static", "playground")
+ if err := os.MkdirAll(static, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ run := func(dir string, env []string, name string, args ...string) {
+ t.Helper()
+ cmd := exec.Command(name, args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), env...)
+ var out bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &out, &out
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("%s %v: %v\n%s", name, args, err, out.String())
+ }
+ }
+ run(".", []string{"GOOS=js", "GOARCH=wasm"}, "go", "build", "-trimpath", "-o",
+ filepath.Join(static, "playground.wasm"), "./cmd/playground-wasm")
+ execJS, err := os.ReadFile(filepath.Join(runtime.GOROOT(), "lib", "wasm", "wasm_exec.js"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(static, "wasm_exec.js"), execJS, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ public := t.TempDir()
+ run(site, nil, hugoArgv[0], append(hugoArgv[1:], "--minify", "--destination", public)...)
+
+ srv := httptest.NewServer(http.FileServer(http.Dir(public)))
+ defer srv.Close()
+
+ cmd := exec.Command(node, "browsertest/driver.cjs", srv.URL+"/playground/", chrome, captureDir(t))
+ cmd.Env = append(os.Environ(), "NODE_PATH="+nodePath)
+ out, err := cmd.CombinedOutput()
+ var res struct {
+ OK bool `json:"ok"`
+ Error string `json:"error"`
+ Results []struct {
+ Name, Detail string
+ OK bool
+ } `json:"results"`
+ }
+ for _, line := range strings.Split(string(out), "\n") {
+ if rest, ok := strings.CutPrefix(line, "RESULT "); ok {
+ _ = json.Unmarshal([]byte(rest), &res)
+ }
+ }
+ for _, r := range res.Results {
+ t.Logf("%-5v %s %s", r.OK, r.Name, r.Detail)
+ }
+ if err != nil || !res.OK {
+ t.Fatalf("browser proof failed: %v %s\n%s", err, res.Error, out)
+ }
+}
diff --git a/playground/browsertest/driver.cjs b/playground/browsertest/driver.cjs
new file mode 100644
index 0000000..a1d03e5
--- /dev/null
+++ b/playground/browsertest/driver.cjs
@@ -0,0 +1,211 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+//
+// Browser proof of the go-ndarray playground, driven by browser_test.go.
+//
+// It loads the BUILT page (Hugo layout + the wasm compiled from this module)
+// in headless Chrome, with the colour scheme FORCED, and drives it the way a
+// visitor does: real mouse clicks at widget rectangles, real key presses. It
+// checks what the app reports (ndarrayDebug) AND what is on the screen
+// (canvas pixels), because a flag saying "shown" is not a picture.
+//
+// Usage: node driver.cjs
+// Prints one line "RESULT " and exits non-zero on the first failure.
+"use strict";
+const puppeteer = require("puppeteer-core");
+const path = require("path");
+
+const [url, chrome, captureDir] = process.argv.slice(2);
+const results = [];
+function check(name, ok, detail) {
+ results.push({ name, ok: !!ok, detail: detail === undefined ? "" : String(detail) });
+ if (!ok) throw new Error("FAILED: " + name + (detail !== undefined ? " — " + detail : ""));
+}
+
+// Theme backgrounds the canvas must paint (app.go Theme), as on the landing.
+const BG = { light: [0xff, 0xff, 0xff], dark: [0x0b, 0x0e, 0x14] };
+const VIRIDIS_ENDS = [[0x44, 0x01, 0x54], [0xfd, 0xe7, 0x25]];
+
+async function run(scheme, dpr) {
+ const browser = await puppeteer.launch({
+ executablePath: chrome, headless: true,
+ args: ["--no-sandbox", "--disable-gpu", "--blink-settings=preferredColorScheme=" + (scheme === "dark" ? 0 : 1)],
+ });
+ try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on("pageerror", (e) => errors.push(String(e)));
+ page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
+ await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: dpr });
+ await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: scheme }]);
+ await page.evaluateOnNewDocument(() => { try { localStorage.removeItem("theme"); } catch (e) {} });
+ await page.goto(url, { waitUntil: "load" });
+ await page.waitForFunction(() => globalThis.ndarrayPlaygroundReady === true &&
+ document.querySelector(".stage").classList.contains("ready"), { timeout: 60000 });
+ const tag = scheme + "@" + dpr + "x: ";
+
+ const debug = async () => JSON.parse(await page.evaluate(() => ndarrayDebug()));
+ const canvasBox = await page.evaluate(() => { const r = document.getElementById("nd-canvas").getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; });
+ const rects = async () => JSON.parse(await page.evaluate(() => ndarrayRects()));
+ const center = (r) => [canvasBox.x + r.x + r.w / 2, canvasBox.y + r.y + r.h / 2];
+ const clickAt = async (x, y) => { await page.mouse.click(x, y); await new Promise((r) => setTimeout(r, 60)); };
+ const click = async (name) => { const r = (await rects())[name]; const [x, y] = center(r); await clickAt(x, y); };
+ // Pixels of the canvas inside a CSS rectangle (relative to the canvas).
+ const pixels = async (r) => page.evaluate((r, dpr) => {
+ const c = document.getElementById("nd-canvas");
+ const ctx = c.getContext("2d");
+ const d = ctx.getImageData(Math.round(r.x * dpr), Math.round(r.y * dpr), Math.max(1, Math.round(r.w * dpr)), Math.max(1, Math.round(r.h * dpr))).data;
+ return Array.from(d);
+ }, r, dpr);
+ const near = (p, i, c, tol) => Math.abs(p[i] - c[0]) <= tol && Math.abs(p[i + 1] - c[1]) <= tol && Math.abs(p[i + 2] - c[2]) <= tol;
+ const count = (p, c, tol) => { let n = 0; for (let i = 0; i < p.length; i += 4) if (near(p, i, c, tol)) n++; return n; };
+ // Pick an option of a dropdown: open it, then click its row in the popover.
+ const pick = async (dd, option) => {
+ const d = await debug();
+ const list = dd === "ops" ? d.ops : d.presets;
+ const idx = list.indexOf(option);
+ check(tag + "option " + option + " exists in " + dd, idx >= 0, list.join(","));
+ await click(dd);
+ const pop = (await rects())[dd + "Popover"];
+ const visible = Math.min(list.length, 12);
+ const rowH = pop.h / visible;
+ // The popover keeps its scroll between openings: wheel it back to the
+ // top, then down until the option is in its window.
+ await page.mouse.move(canvasBox.x + pop.x + pop.w / 2, canvasBox.y + pop.y + pop.h / 2);
+ for (let i = 0; i < list.length; i++) await page.mouse.wheel({ deltaY: -40 });
+ await new Promise((r) => setTimeout(r, 40));
+ let first = 0;
+ while (idx >= first + visible) {
+ await page.mouse.move(canvasBox.x + pop.x + pop.w / 2, canvasBox.y + pop.y + pop.h / 2);
+ await page.mouse.wheel({ deltaY: 40 });
+ await new Promise((r) => setTimeout(r, 40));
+ first++;
+ }
+ await clickAt(canvasBox.x + pop.x + pop.w / 2, canvasBox.y + pop.y + (idx - first + 0.5) * rowH);
+ };
+ const typeArgs = async (text) => {
+ await click("args");
+ await page.keyboard.press("End");
+ for (let i = 0; i < 40; i++) await page.keyboard.press("Backspace");
+ await page.keyboard.type(text);
+ };
+
+ // 1. The page opened on the README example, in the forced theme.
+ let d = await debug();
+ check(tag + "opens on the README example (7 steps)", d.steps.length === 7, d.steps.length);
+ check(tag + "the last step is selected", d.selected === 6, d.selected);
+ check(tag + "the canvas follows the forced theme", d.dark === (scheme === "dark"), d.dark);
+ check(tag + "the heatmap table shows the (2, 3) result", d.rows === 2 && d.cols === 4, d.rows + "x" + d.cols);
+ const corner = await pixels({ x: 2, y: 2, w: 1, h: 1 });
+ check(tag + "canvas background is the theme's", near(corner, 0, BG[scheme], 2), corner.slice(0, 3));
+ const tbl = (await rects()).table;
+ const tp = await pixels(tbl);
+ const heat = count(tp, VIRIDIS_ENDS[0], 12) + count(tp, VIRIDIS_ENDS[1], 12);
+ check(tag + "the table is painted as a heatmap (viridis end colours on screen)", heat > 200, heat + " px");
+ check(tag + "the memory line names the layout", /strides \(3, 1\) elements/.test(d.memory), d.memory);
+ await page.screenshot({ path: path.join(captureDir, "open-" + scheme + "-" + dpr + "x.png") });
+
+ // 2. Click a step of the pipeline: b, the reshaped view.
+ const st = (await rects()).steps;
+ // Probe down the list until the click lands on row b (index 1): the test
+ // does not assume the list's row height.
+ for (let y = st.y + 4; y < st.y + st.h; y += 4) {
+ await clickAt(canvasBox.x + st.x + 40, canvasBox.y + y);
+ d = await debug();
+ if (d.selected === 1) break;
+ }
+ check(tag + "clicking step b selects it", d.selected === 1, d.selected);
+ check(tag + "step b is reported as a view of a", /VIEW: shares memory with a/.test(d.memory), d.memory);
+ check(tag + "step b is the 2x3 table", d.rows === 2 && d.cols === 4, d.rows + "x" + d.cols);
+
+ // 3. Pick Transpose, input b, Apply: a new view step.
+ await pick("ops", "Transpose");
+ d = await debug();
+ check(tag + "the operation menu selected Transpose", d.op === "Transpose", d.op);
+ await click("apply");
+ d = await debug();
+ check(tag + "Apply added a step", d.steps.length === 8, d.steps.join(" | "));
+ check(tag + "the selected step b became the input", d.steps[7].startsWith("h = b.Transpose()"), d.steps[7]);
+ check(tag + "the transpose is a non-contiguous view of b's storage (as are a, d and f)", /not contiguous/.test(d.memory) && /VIEW: shares memory with a, b, d, f$/.test(d.memory), d.memory);
+ check(tag + "its table is 3x2 (+ the row-label column)", d.rows === 3 && d.cols === 3, d.rows + "x" + d.cols);
+
+ // 4. An error, reported as go-ndarray reports it.
+ await pick("ops", "Reshape");
+ await typeArgs("4, 4");
+ await page.keyboard.press("Enter");
+ await new Promise((r) => setTimeout(r, 60));
+ d = await debug();
+ check(tag + "Enter in the argument field applies", d.error !== "", d.error);
+ check(tag + "a bad reshape shows go-ndarray's own error", /^ndarray: shape mismatch/.test(d.error), d.error);
+ check(tag + "the failed step was not added", d.steps.length === 8, d.steps.length);
+ const ep = await pixels((await rects()).error);
+ const red = scheme === "dark" ? [0xf7, 0x8b, 0x8b] : [0xb4, 0x23, 0x18];
+ check(tag + "the error is on screen in red", count(ep, red, 40) > 20, count(ep, red, 40) + " px");
+ await page.screenshot({ path: path.join(captureDir, "error-" + scheme + "-" + dpr + "x.png") });
+
+ // 5. The code panel: Go program, then the NumPy equivalent.
+ check(tag + "the Go program is shown", d.code.startsWith("package main") && d.code.includes("ndarray.Arange(0, 6, 1)"), d.code.slice(0, 40));
+ const lang = (await rects()).lang;
+ await clickAt(canvasBox.x + lang.x + lang.w * 0.75, canvasBox.y + lang.y + lang.h / 2);
+ d = await debug();
+ check(tag + "the NumPy tab shows NumPy", d.code.startsWith("import numpy as np") && d.code.includes("b.T"), d.code.slice(0, 40));
+
+ // 6. Copy puts the shown program on the clipboard.
+ await browser.defaultBrowserContext().overridePermissions(new URL(url).origin, ["clipboard-read", "clipboard-write", "clipboard-sanitized-write"]);
+ await click("copy");
+ const clip = await page.evaluate(() => navigator.clipboard.readText());
+ check(tag + "Copy copies the program shown", clip === d.code, clip.slice(0, 40));
+
+ // 7. Presets: the shape-mismatch example, then Apply.
+ await pick("presets", "A shape mismatch");
+ d = await debug();
+ check(tag + "the preset loaded 3 steps", d.steps.length === 3, d.steps.join(" | "));
+ check(tag + "the preset proposes Add b + c", d.op === "Add" && d.input === "b" && d.args === "c", d.op + " " + d.input + " " + d.args);
+ await click("apply");
+ d = await debug();
+ check(tag + "broadcasting (2,3) with (3,2) fails as go-ndarray says", /^ndarray: shapes are not broadcastable: \[2 3\] vs \[3 2\]/.test(d.error), d.error);
+
+ // 8. go-ndarray's helper goroutines under js/wasm: GOMAXPROCS is 1, so a
+ // large operation (past the parallel threshold) must start none.
+ const g0 = d.goroutines;
+ await pick("ops", "Arange");
+ await typeArgs("0, 1000000, 1");
+ await page.keyboard.press("Enter");
+ await pick("ops", "Exp");
+ await click("apply");
+ await pick("ops", "Sum");
+ await typeArgs("");
+ await click("apply");
+ d = await debug();
+ check(tag + "the large steps ran", d.steps.length === 6 && d.error === "", d.steps.join(" | ") + " / " + d.error);
+ check(tag + "GOMAXPROCS is 1 under js/wasm", d.maxProcs === 1, d.maxProcs);
+ check(tag + "no helper goroutine started for 1e6-element operations", d.goroutines === g0, g0 + " -> " + d.goroutines);
+ check(tag + "the timing is reported", /per call in this browser/.test(d.timing), d.timing);
+
+ // 9. The page's theme toggle recolours the canvas (System -> Light -> Dark).
+ await page.click("#theme-toggle"); // light
+ await new Promise((r) => setTimeout(r, 100));
+ let px = await pixels({ x: 2, y: 2, w: 1, h: 1 });
+ check(tag + "toggle to Light paints the light background", near(px, 0, BG.light, 2) && (await debug()).dark === false, px.slice(0, 3));
+ await page.click("#theme-toggle"); // dark
+ await new Promise((r) => setTimeout(r, 100));
+ px = await pixels({ x: 2, y: 2, w: 1, h: 1 });
+ check(tag + "toggle to Dark paints the dark background", near(px, 0, BG.dark, 2) && (await debug()).dark === true, px.slice(0, 3));
+
+ check(tag + "no page or console error", errors.length === 0, errors.join(" | "));
+ } finally {
+ await browser.close();
+ }
+}
+
+(async () => {
+ try {
+ await run("light", 1);
+ await run("dark", 1);
+ await run("light", 2);
+ console.log("RESULT " + JSON.stringify({ ok: true, results }));
+ } catch (e) {
+ console.log("RESULT " + JSON.stringify({ ok: false, error: String(e.message || e), results }));
+ process.exit(1);
+ }
+})();
diff --git a/playground/capture_test.go b/playground/capture_test.go
new file mode 100644
index 0000000..d7e998c
--- /dev/null
+++ b/playground/capture_test.go
@@ -0,0 +1,68 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package playground
+
+import (
+ "image"
+ "image/png"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// captureDir is where a test writes a rendered frame for a person to look at:
+// $PLAYGROUND_CAPTURE_DIR, else /go-ndarray-playground/captures.
+// It is durable (unlike t.TempDir) and it is refused when it lies inside a git
+// work tree, so a capture can never be one `git add` away from publication.
+func captureDir(t *testing.T) string {
+ t.Helper()
+ dir := os.Getenv("PLAYGROUND_CAPTURE_DIR")
+ if dir == "" {
+ base, err := os.UserConfigDir()
+ if err != nil {
+ t.Skipf("no user config dir: %v", err)
+ }
+ dir = filepath.Join(base, "go-ndarray-playground", "captures")
+ }
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tree := gitTreeOf(abs); tree != "" {
+ t.Fatalf("refusing to write captures inside the git work tree %s", tree)
+ }
+ if err := os.MkdirAll(abs, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ return abs
+}
+
+// gitTreeOf returns the first ancestor of dir (or dir itself) holding a .git,
+// or "" when there is none up to the filesystem root.
+func gitTreeOf(dir string) string {
+ for d := dir; ; d = filepath.Dir(d) {
+ if _, err := os.Stat(filepath.Join(d, ".git")); err == nil {
+ return d
+ }
+ if filepath.Dir(d) == d {
+ return ""
+ }
+ }
+}
+
+// savePNG writes an RGBA frame to the capture directory and logs where.
+func savePNG(t *testing.T, name string, buf []byte, w, h int) {
+ t.Helper()
+ img := &image.RGBA{Pix: buf, Stride: 4 * w, Rect: image.Rect(0, 0, w, h)}
+ path := filepath.Join(captureDir(t), name)
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if err := png.Encode(f, img); err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("capture: %s", path)
+}
diff --git a/playground/cmd/playground-wasm/main.go b/playground/cmd/playground-wasm/main.go
new file mode 100644
index 0000000..6912d30
--- /dev/null
+++ b/playground/cmd/playground-wasm/main.go
@@ -0,0 +1,224 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build js && wasm
+
+// Command playground-wasm is the browser shell of the go-ndarray playground:
+// it sizes a to CSS pixels × devicePixelRatio, blits the View's RGBA
+// frame into it, and forwards mouse, wheel and keyboard input. Everything else
+// — the widgets, the ViewModel, go-ndarray itself — is the tagless, natively
+// tested package playground; this file is the thin, coverage-excluded shell,
+// shaped after the go-tex playground's.
+//
+// It publishes, for the host page and for the headless proofs:
+//
+// ndarrayPlaygroundReady true once the first frame is painted
+// ndarraySetTheme(dark) recolour (the page calls it when its theme changes)
+// ndarrayDebug() a JSON snapshot of the scene, for tests
+// ndarrayRects() the widgets' rectangles in CSS pixels, for tests
+//
+// The page owns the theme: the landing's own System/Light/Dark toggle sits in
+// its nav bar, and a change of data-theme reaches the canvas through
+// ndarraySetTheme.
+package main
+
+import (
+ "encoding/json"
+ "runtime"
+ "strconv"
+ "syscall/js"
+
+ playground "github.com/go-ndarray/go-ndarray.github.io/playground"
+)
+
+const canvasID = "nd-canvas"
+
+// buildVersion is stamped by the deploy (-ldflags -X main.buildVersion=).
+var buildVersion = "dev"
+
+func main() {
+ doc := js.Global().Get("document")
+ canvas := doc.Call("getElementById", canvasID)
+ if canvas.IsUndefined() || canvas.IsNull() {
+ println("playground-wasm: no #" + canvasID + " canvas in the host page")
+ return
+ }
+ ctx := canvas.Call("getContext", "2d")
+
+ dpr := func() float64 {
+ if r := js.Global().Get("devicePixelRatio"); r.Type() == js.TypeNumber && r.Float() > 0 {
+ return r.Float()
+ }
+ return 1
+ }
+ deviceSize := func() (int, int, float64) {
+ w := max(canvas.Get("clientWidth").Int(), 320)
+ h := max(canvas.Get("clientHeight").Int(), 480)
+ d := dpr()
+ return int(float64(w)*d + 0.5), int(float64(h)*d + 0.5), d
+ }
+
+ dw, dh, d := deviceSize()
+ playground.SetupText(d)
+ canvas.Set("width", dw)
+ canvas.Set("height", dh)
+ curDPR := d
+
+ state := playground.NewState(dw, dh, pageIsDark(doc))
+ state.VM.SetRuntime(runtime.GOOS + "/" + runtime.GOARCH + ", GOMAXPROCS " +
+ strconv.Itoa(runtime.GOMAXPROCS(0)) + ", build " + buildVersion)
+ state.VM.Clipboard = func(s string) {
+ if clip := js.Global().Get("navigator").Get("clipboard"); clip.Truthy() {
+ clip.Call("writeText", s)
+ }
+ }
+ var local []byte
+ var imageData, dst js.Value
+ alloc := func(w, h int) {
+ local = make([]byte, 4*w*h)
+ imageData = ctx.Call("createImageData", w, h)
+ dst = imageData.Get("data")
+ }
+ alloc(dw, dh)
+ render := func() {
+ state.Draw(local)
+ js.CopyBytesToJS(dst, local)
+ ctx.Call("putImageData", imageData, 0, 0)
+ }
+
+ coords := func(e js.Value) (int, int) {
+ rect := canvas.Call("getBoundingClientRect")
+ sx := rect.Get("width").Float() / canvas.Get("width").Float()
+ sy := rect.Get("height").Float() / canvas.Get("height").Float()
+ if sx == 0 || sy == 0 {
+ return 0, 0
+ }
+ return int((e.Get("clientX").Float() - rect.Get("left").Float()) / sx),
+ int((e.Get("clientY").Float() - rect.Get("top").Float()) / sy)
+ }
+ on := func(target js.Value, name string, fn func(e js.Value) bool, opts ...any) {
+ target.Call("addEventListener", append([]any{name, js.FuncOf(func(_ js.Value, args []js.Value) any {
+ if len(args) > 0 && fn(args[0]) {
+ render()
+ }
+ return nil
+ })}, opts...)...)
+ }
+ on(canvas, "mousedown", func(e js.Value) bool {
+ if e.Get("button").Int() != 0 {
+ return false
+ }
+ x, y := coords(e)
+ return state.HandleClick(x, y)
+ })
+ on(js.Global(), "mousemove", func(e js.Value) bool {
+ x, y := coords(e)
+ return state.HandleMove(x, y)
+ })
+ on(js.Global(), "mouseup", func(e js.Value) bool {
+ x, y := coords(e)
+ return state.HandleRelease(x, y)
+ })
+ on(canvas, "wheel", func(e js.Value) bool {
+ e.Call("preventDefault")
+ x, y := coords(e)
+ return state.HandleScroll(x, y, wheelRows(e.Get("deltaX").Float()), wheelRows(e.Get("deltaY").Float()))
+ }, map[string]any{"passive": false})
+ on(js.Global(), "keydown", func(e js.Value) bool {
+ key := e.Get("key").String()
+ mod := e.Get("ctrlKey").Bool() || e.Get("metaKey").Bool()
+ if e.Get("isComposing").Bool() || key == "Dead" || isModifier(key) {
+ return false
+ }
+ var changed bool
+ if len([]rune(key)) == 1 && !mod {
+ changed = state.HandleChar(key)
+ } else if !mod {
+ if key == "Tab" && e.Get("shiftKey").Bool() {
+ key = "Shift+Tab"
+ }
+ changed = state.HandleKeyDown(key)
+ }
+ if changed {
+ e.Call("preventDefault")
+ }
+ return changed
+ })
+
+ refit := func() {
+ nw, nh, nd := deviceSize()
+ ow, oh := state.Size()
+ if nw == ow && nh == oh && nd == curDPR {
+ return
+ }
+ if nd != curDPR {
+ playground.SetupText(nd)
+ curDPR = nd
+ }
+ canvas.Set("width", nw)
+ canvas.Set("height", nh)
+ alloc(nw, nh)
+ state.Resize(nw, nh)
+ render()
+ }
+ js.Global().Call("addEventListener", "resize", js.FuncOf(func(js.Value, []js.Value) any { refit(); return nil }))
+
+ js.Global().Set("ndarraySetTheme", js.FuncOf(func(_ js.Value, args []js.Value) any {
+ if len(args) > 0 {
+ state.SetTheme(args[0].Bool())
+ render()
+ }
+ return nil
+ }))
+ js.Global().Set("ndarrayDebug", js.FuncOf(func(js.Value, []js.Value) any {
+ b, _ := json.Marshal(state.Debug())
+ return string(b)
+ }))
+ js.Global().Set("ndarrayRects", js.FuncOf(func(js.Value, []js.Value) any {
+ out := map[string]any{}
+ for name, r := range state.Rects() {
+ out[name] = map[string]any{
+ "x": float64(r.X) / curDPR, "y": float64(r.Y) / curDPR,
+ "w": float64(r.W) / curDPR, "h": float64(r.H) / curDPR,
+ }
+ }
+ b, _ := json.Marshal(out)
+ return string(b)
+ }))
+
+ render()
+ js.Global().Set("ndarrayPlaygroundReady", true)
+ select {}
+}
+
+// pageIsDark resolves the page theme: data-theme, else the OS preference.
+func pageIsDark(doc js.Value) bool {
+ switch doc.Get("documentElement").Call("getAttribute", "data-theme").String() {
+ case "dark":
+ return true
+ case "light":
+ return false
+ }
+ mq := js.Global().Call("matchMedia", "(prefers-color-scheme: dark)")
+ return mq.Truthy() && mq.Get("matches").Bool()
+}
+
+// wheelRows turns a wheel delta in CSS pixels into toolkit scroll rows.
+func wheelRows(d float64) int {
+ switch {
+ case d > 0:
+ return max(1, int(d/40))
+ case d < 0:
+ return min(-1, int(d/40))
+ }
+ return 0
+}
+
+// isModifier reports a bare modifier key, which types nothing.
+func isModifier(k string) bool {
+ switch k {
+ case "Shift", "Control", "Alt", "Meta", "CapsLock", "AltGraph":
+ return true
+ }
+ return false
+}
diff --git a/playground/cmd/playground-wasm/stub.go b/playground/cmd/playground-wasm/stub.go
new file mode 100644
index 0000000..d3f77b9
--- /dev/null
+++ b/playground/cmd/playground-wasm/stub.go
@@ -0,0 +1,11 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Native build stub: the real entry point is wasm-only (see main.go). This
+// keeps `go build ./...` and `go test ./...` green on every host.
+//
+//go:build !js || !wasm
+
+package main
+
+func main() {}
diff --git a/playground/debug.go b/playground/debug.go
new file mode 100644
index 0000000..41f40fc
--- /dev/null
+++ b/playground/debug.go
@@ -0,0 +1,66 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package playground
+
+import (
+ "runtime"
+
+ "github.com/go-widgets/toolkit"
+)
+
+// Debug is a snapshot of what the scene shows, for the browser proofs: they
+// assert on it after driving the page with real mouse and keyboard events.
+type Debug struct {
+ Steps []string `json:"steps"`
+ Selected int `json:"selected"`
+ Op string `json:"op"`
+ Input string `json:"input"`
+ Args string `json:"args"`
+ Error string `json:"error"`
+ Title string `json:"title"`
+ Shape string `json:"shape"`
+ Memory string `json:"memory"`
+ Timing string `json:"timing"`
+ Rows int `json:"rows"`
+ Cols int `json:"cols"`
+ Dark bool `json:"dark"`
+ Status string `json:"status"`
+ Code string `json:"code"`
+ Ops []string `json:"ops"`
+ Presets []string `json:"presets"`
+ // Goroutines and MaxProcs let a proof watch go-ndarray's helper pool
+ // under GOOS=js: GOMAXPROCS is 1 there, so no helper should ever start.
+ Goroutines int `json:"goroutines"`
+ MaxProcs int `json:"maxProcs"`
+}
+
+// Debug reports the scene's current state.
+func (s *State) Debug() Debug {
+ v := s.VM
+ d := Debug{
+ Steps: v.Steps.Slice(), Selected: v.Selected.Get(),
+ Op: v.OpNames[v.OpIndex.Get()], Args: v.Args.Get(), Error: v.Error.Get(),
+ Title: v.Title.Get(), Shape: v.Shape.Get(), Memory: v.Memory.Get(), Timing: v.Timing.Get(),
+ Rows: len(s.table.Rows), Cols: len(s.table.Columns),
+ Dark: s.dark, Status: v.Status.Get(), Code: v.Code.Get(),
+ Ops: v.OpNames, Presets: v.PresetNames,
+ Goroutines: runtime.NumGoroutine(), MaxProcs: runtime.GOMAXPROCS(0),
+ }
+ if i := v.InputIndex.Get(); i >= 0 && i < v.Inputs.Len() {
+ d.Input = v.Inputs.At(i)
+ }
+ return d
+}
+
+// Rects names the rectangles of the widgets a test clicks, in device pixels.
+func (s *State) Rects() map[string]toolkit.Rect {
+ return map[string]toolkit.Rect{
+ "presets": s.presets.Bounds(), "reset": s.reset.Bounds(),
+ "steps": s.steps.Bounds(), "ops": s.ops.Bounds(), "inputs": s.inputs.Bounds(),
+ "args": s.args.Bounds(), "apply": s.apply.Bounds(), "undo": s.undo.Bounds(),
+ "error": s.errLabel.Bounds(), "table": s.table.Bounds(), "lang": s.lang.Bounds(),
+ "copy": s.copyB.Bounds(), "code": s.code.Bounds(), "opsPopover": s.ops.PopoverBounds(),
+ "presetsPopover": s.presets.PopoverBounds(),
+ }
+}
diff --git a/playground/engine/args.go b/playground/engine/args.go
new file mode 100644
index 0000000..df0b8b0
--- /dev/null
+++ b/playground/engine/args.go
@@ -0,0 +1,143 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package engine
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// splitArgs splits an argument line on the commas that are not inside
+// brackets or parentheses, trimming each piece. An empty (or all-blank) line
+// is no arguments at all, not one empty argument.
+func splitArgs(s string) []string {
+ if strings.TrimSpace(s) == "" {
+ return nil
+ }
+ var out []string
+ depth, start := 0, 0
+ for i, r := range s {
+ switch r {
+ case '[', '(':
+ depth++
+ case ']', ')':
+ depth--
+ case ',':
+ if depth == 0 {
+ out = append(out, strings.TrimSpace(s[start:i]))
+ start = i + 1
+ }
+ }
+ }
+ return append(out, strings.TrimSpace(s[start:]))
+}
+
+// parseFloat reads one number. It accepts what strconv does plus the
+// spellings a NumPy user types: inf, -inf, nan (any case).
+func parseFloat(s string) (float64, error) {
+ v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
+ if err != nil {
+ return 0, fmt.Errorf("%q is not a number", s)
+ }
+ return v, nil
+}
+
+// parseInt reads one integer.
+func parseInt(s string) (int, error) {
+ v, err := strconv.Atoi(strings.TrimSpace(s))
+ if err != nil {
+ return 0, fmt.Errorf("%q is not an integer", s)
+ }
+ return v, nil
+}
+
+// parseInts reads every argument as an integer; a shape written as a tuple,
+// "(2, 3)", is unwrapped first so both spellings work.
+func parseInts(args []string) ([]int, error) {
+ if len(args) == 1 && strings.HasPrefix(args[0], "(") && strings.HasSuffix(args[0], ")") {
+ args = splitArgs(args[0][1 : len(args[0])-1])
+ }
+ out := make([]int, 0, len(args))
+ for _, a := range args {
+ if a == "" {
+ continue // a trailing comma, as in the tuple "(3,)"
+ }
+ v, err := parseInt(a)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+ }
+ return out, nil
+}
+
+// parseFloatList reads a bracketed list of numbers, "[1, 2.5, 3]".
+func parseFloatList(s string) ([]float64, error) {
+ s = strings.TrimSpace(s)
+ if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
+ return nil, fmt.Errorf("values must be a bracketed list, like [1, 2, 3]")
+ }
+ parts := splitArgs(s[1 : len(s)-1])
+ out := make([]float64, 0, len(parts))
+ for _, p := range parts {
+ if p == "" {
+ continue
+ }
+ v, err := parseFloat(p)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+ }
+ return out, nil
+}
+
+// formatFloat writes a number the way Go source and NumPy both accept it.
+func formatFloat(v float64) string {
+ return strconv.FormatFloat(v, 'g', -1, 64)
+}
+
+// joinInts writes "2, 3".
+func joinInts(v []int) string {
+ s := make([]string, len(v))
+ for i, x := range v {
+ s[i] = strconv.Itoa(x)
+ }
+ return strings.Join(s, ", ")
+}
+
+// pyTuple writes a NumPy shape tuple: "(3,)" for one axis, "(2, 3)" for two.
+func pyTuple(v []int) string {
+ if len(v) == 1 {
+ return "(" + strconv.Itoa(v[0]) + ",)"
+ }
+ return "(" + joinInts(v) + ")"
+}
+
+// isName reports whether s is a step name: a lower-case letter followed by
+// letters or digits.
+func isName(s string) bool {
+ if s == "" || s[0] < 'a' || s[0] > 'z' {
+ return false
+ }
+ for i := 1; i < len(s); i++ {
+ c := s[i]
+ if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9') {
+ return false
+ }
+ }
+ return true
+}
+
+// StepName returns the name of the i-th step (0-based): a, b, …, z, then
+// a1, b1, …, so every name is a valid identifier in both Go and Python that
+// is not a keyword of either.
+func StepName(i int) string {
+ letter := string(rune('a' + i%26))
+ if i < 26 {
+ return letter
+ }
+ return letter + strconv.Itoa(i/26)
+}
diff --git a/playground/engine/engine_test.go b/playground/engine/engine_test.go
new file mode 100644
index 0000000..29a01bf
--- /dev/null
+++ b/playground/engine/engine_test.go
@@ -0,0 +1,458 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package engine
+
+import (
+ "errors"
+ "math"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/go-ndarray/ndarray"
+)
+
+// base builds a session holding a = Arange(0,6,1), b = a.Reshape(2,3),
+// c = FromData([10,20,30]) and s = b.Max() (a number), the inputs the
+// operation table below reads.
+func base(t *testing.T) *Session {
+ t.Helper()
+ s := &Session{}
+ for _, c := range []Call{{"Arange", "", "0, 6, 1"}, {"Reshape", "a", "2, 3"}, {"FromData", "", "[10, 20, 30]"}, {"Max", "b", ""}} {
+ if _, err := s.Apply(c.Op, c.Input, c.Args); err != nil {
+ t.Fatalf("%v: %v", c, err)
+ }
+ }
+ return s
+}
+
+// Every operation: its result shape, its Go statement and its NumPy one.
+func TestOperations(t *testing.T) {
+ cases := []struct {
+ op, in, args string
+ shape string
+ goStmt, py string
+ }{
+ {"Arange", "", "0, 3, 0.5", "(6,)", "e, err := ndarray.Arange(0, 3, 0.5)", "e = np.arange(0, 3, 0.5)"},
+ {"Linspace", "", "0, 1, 5", "(5,)", "e, err := ndarray.Linspace(0, 1, 5)", "e = np.linspace(0, 1, 5)"},
+ {"Zeros", "", "2, 3", "(2, 3)", "e, err := ndarray.Zeros(2, 3)", "e = np.zeros((2, 3))"},
+ {"Ones", "", "(4,)", "(4,)", "e, err := ndarray.Ones(4)", "e = np.ones((4,))"},
+ {"Full", "", "7, 2, 2", "(2, 2)", "e, err := ndarray.Full(7, 2, 2)", "e = np.full((2, 2), 7)"},
+ {"Full", "", "7", "()", "e, err := ndarray.Full(7)", "e = np.full((), 7)"},
+ {"Eye", "", "3, 4, 1", "(3, 4)", "e, err := ndarray.Eye(3, 4, 1)", "e = np.eye(3, 4, 1)"},
+ {"FromData", "", "[1, 2, 3, 4], 2, 2", "(2, 2)", "e, err := ndarray.FromData([]float64{1, 2, 3, 4}, 2, 2)", "e = np.array([1, 2, 3, 4]).reshape(2, 2)"},
+ {"FromData", "", "[1, 2,]", "(2,)", "e, err := ndarray.FromData([]float64{1, 2}, 2)", "e = np.array([1, 2])"},
+ {"Reshape", "a", "3, -1", "(3, 2)", "e, err := a.Reshape(3, -1)", "e = a.reshape(3, -1)"},
+ {"Transpose", "b", "", "(3, 2)", "e := b.Transpose()", "e = b.T"},
+ {"Flatten", "b", "", "(6,)", "e := b.Flatten()", "e = b.flatten()"},
+ {"Copy", "b", "", "(2, 3)", "e := b.Copy()", "e = b.copy()"},
+ {"Slice", "b", "1:, ::-1", "(1, 3)", "e, err := b.Slice(ndarray.From(1), ndarray.Step(-1))", "e = b[1:, ::-1]"},
+ {"Slice", "b", ":, :2", "(2, 2)", "e, err := b.Slice(ndarray.All(), ndarray.To(2))", "e = b[:, :2]"},
+ {"Slice", "b", "0, 0:3:2", "(2,)", "e, err := b.Slice(ndarray.A(0), ndarray.Rng(0, 3, 2))", "e = b[0, 0:3:2]"},
+ {"Slice", "b", "0, 1::2", "(1,)", "e, err := b.Slice(ndarray.A(0), ndarray.Rng(1, 3, 2))", "e = b[0, 1::2]"},
+ {"Slice", "b", "0, :0:-1", "(2,)", "e, err := b.Slice(ndarray.A(0), ndarray.Rng(2, 0, -1))", "e = b[0, :0:-1]"},
+ {"Slice", "b", "0, 1::-1", "(2,)", "e, err := b.Slice(ndarray.A(0), ndarray.Rng(1, -4, -1))", "e = b[0, 1::-1]"},
+ {"Slice", "b", "0, 1:3", "(2,)", "e, err := b.Slice(ndarray.A(0), ndarray.R(1, 3))", "e = b[0, 1:3]"},
+ {"Squeeze", "b", "", "(2, 3)", "e, err := b.Squeeze()", "e = np.squeeze(b)"},
+ {"ExpandDims", "b", "0", "(1, 2, 3)", "e, err := b.ExpandDims(0)", "e = np.expand_dims(b, 0)"},
+ {"Exp", "b", "", "(2, 3)", "e := b.Exp()", "e = np.exp(b)"},
+ {"Log", "b", "", "(2, 3)", "e := b.Log()", "e = np.log(b)"},
+ {"Sqrt", "b", "", "(2, 3)", "e := b.Sqrt()", "e = np.sqrt(b)"},
+ {"Sin", "b", "", "(2, 3)", "e := b.Sin()", "e = np.sin(b)"},
+ {"Cos", "b", "", "(2, 3)", "e := b.Cos()", "e = np.cos(b)"},
+ {"Abs", "b", "", "(2, 3)", "e := b.Abs()", "e = np.abs(b)"},
+ {"Square", "b", "", "(2, 3)", "e := b.Square()", "e = np.square(b)"},
+ {"Power", "b", "3", "(2, 3)", "e := b.Power(3)", "e = b ** 3"},
+ {"Clip", "b", "1, 4", "(2, 3)", "e, err := b.Clip(1, 4)", "e = np.clip(b, 1, 4)"},
+ {"Sum", "b", "", "()", "e := b.Sum()", "e = b.sum()"},
+ {"Sum", "b", "1", "(2,)", "e, err := b.SumAxis(1, false)", "e = b.sum(axis=1)"},
+ {"Mean", "b", "0, keepdims", "(1, 3)", "e, err := b.MeanAxis(0, true)", "e = b.mean(axis=0, keepdims=True)"},
+ {"Mean", "b", "", "()", "e, err := b.Mean()", "e = b.mean()"},
+ {"Max", "b", "1", "(2,)", "e, err := b.MaxAxis(1, false)", "e = b.max(axis=1)"},
+ {"Min", "b", "", "()", "e, err := b.Min()", "e = b.min()"},
+ {"Min", "b", "0, true", "(1, 3)", "e, err := b.MinAxis(0, true)", "e = b.min(axis=0, keepdims=True)"},
+ {"ArgMax", "b", "", "()", "e, err := b.ArgMax()", "e = b.argmax()"},
+ {"ArgMax", "b", "1", "(2,)", "e, err := b.ArgMaxAxis(1, false)", "e = b.argmax(axis=1)"},
+ {"CumSum", "b", "", "(6,)", "e := b.CumSumFlat()", "e = np.cumsum(b)"},
+ {"CumSum", "b", "1", "(2, 3)", "e, err := b.CumSum(1)", "e = np.cumsum(b, axis=1)"},
+ {"Add", "b", "c", "(2, 3)", "e, err := b.Add(c)", "e = b + c"},
+ {"Add", "b", "1.5", "(2, 3)", "e := b.AddScalar(1.5)", "e = b + 1.5"},
+ {"Sub", "b", "c", "(2, 3)", "e, err := b.Sub(c)", "e = b - c"},
+ {"Sub", "b", "1", "(2, 3)", "e := b.SubScalar(1)", "e = b - 1"},
+ {"Mul", "b", "2", "(2, 3)", "e := b.MulScalar(2)", "e = b * 2"},
+ {"Div", "b", "c", "(2, 3)", "e, err := b.Div(c)", "e = b / c"},
+ {"Div", "b", "2", "(2, 3)", "e := b.DivScalar(2)", "e = b / 2"},
+ {"Mul", "b", "c", "(2, 3)", "e, err := b.Mul(c)", "e = b * c"},
+ {"Maximum", "b", "c", "(2, 3)", "e, err := b.Maximum(c)", "e = np.maximum(b, c)"},
+ {"Minimum", "b", "c", "(2, 3)", "e, err := b.Minimum(c)", "e = np.minimum(b, c)"},
+ {"Dot", "b", "c", "(2,)", "e, err := b.Dot(c)", "e = np.dot(b, c)"},
+ {"Dot", "c", "c", "()", "e, err := c.Dot(c)", "e = np.dot(c, c)"},
+ {"Outer", "c", "a", "(3, 6)", "e := c.Outer(a)", "e = np.outer(c, a)"},
+ {"Concatenate", "a", "a, 0", "(12,)", "e, err := ndarray.Concatenate([]*ndarray.Array{a, a}, 0)", "e = np.concatenate([a, a], axis=0)"},
+ {"Stack", "b", "b, b, 0", "(3, 2, 3)", "e, err := ndarray.Stack([]*ndarray.Array{b, b, b}, 0)", "e = np.stack([b, b, b], axis=0)"},
+ }
+ for _, c := range cases {
+ s := base(t)
+ st, err := s.Apply(c.op, c.in, c.args)
+ if err != nil {
+ t.Errorf("%s(%s) on %q: %v", c.op, c.args, c.in, err)
+ continue
+ }
+ if got := ShapeText(st.Layout.Shape); got != c.shape {
+ t.Errorf("%s(%s): shape %s, want %s", c.op, c.args, got, c.shape)
+ }
+ if st.Go != c.goStmt {
+ t.Errorf("%s(%s): Go %q, want %q", c.op, c.args, st.Go, c.goStmt)
+ }
+ if st.Py != c.py {
+ t.Errorf("%s(%s): NumPy %q, want %q", c.op, c.args, st.Py, c.py)
+ }
+ }
+}
+
+// Squeeze with axes, one and several.
+func TestSqueezeAxes(t *testing.T) {
+ s := &Session{}
+ mustApply(t, s, "Zeros", "", "1, 3, 1")
+ st := mustApply(t, s, "Squeeze", "a", "0")
+ if st.Py != "b = np.squeeze(a, axis=0)" || ShapeText(st.Layout.Shape) != "(3, 1)" {
+ t.Errorf("Squeeze(0): %q %v", st.Py, st.Layout.Shape)
+ }
+ st = mustApply(t, s, "Squeeze", "a", "0, 2")
+ if st.Py != "c = np.squeeze(a, axis=(0, 2))" || ShapeText(st.Layout.Shape) != "(3,)" {
+ t.Errorf("Squeeze(0, 2): %q %v", st.Py, st.Layout.Shape)
+ }
+}
+
+func TestMatMul(t *testing.T) {
+ s := &Session{}
+ mustApply(t, s, "Arange", "", "0, 6, 1")
+ mustApply(t, s, "Reshape", "a", "2, 3")
+ mustApply(t, s, "Transpose", "b", "")
+ st := mustApply(t, s, "MatMul", "b", "c")
+ if st.Go != "d, err := b.MatMul(c)" || st.Py != "d = b @ c" || st.Result.At(1, 1) != 50 {
+ t.Errorf("MatMul: %q %q %v", st.Go, st.Py, st.Result)
+ }
+}
+
+func mustApply(t *testing.T, s *Session, op, in, args string) Step {
+ t.Helper()
+ st, err := s.Apply(op, in, args)
+ if err != nil {
+ t.Fatalf("%s(%s): %v", op, args, err)
+ }
+ return st
+}
+
+// Wrong arguments are refused with a message, and go-ndarray's own errors
+// come through as go-ndarray words them.
+func TestErrors(t *testing.T) {
+ cases := []struct{ op, in, args, want string }{
+ {"Nope", "", "", "unknown operation"},
+ {"Reshape", "", "2", "needs an input"},
+ {"Reshape", "zz", "2", `no step named "zz"`},
+ {"Reshape", "d", "2", "is a number in Go"},
+ {"Arange", "", "0, 1", "Arange takes start, stop, step"},
+ {"Arange", "", "0, x, 1", `"x" is not a number`},
+ {"Arange", "", "0, 1e9, 1", "would exceed"},
+ {"Linspace", "", "0, 1", "Linspace takes"},
+ {"Linspace", "", "x, 1, 2", "not a number"},
+ {"Linspace", "", "0, x, 2", "not a number"},
+ {"Linspace", "", "0, 1, 2.5", "not an integer"},
+ {"Linspace", "", "0, 1, 100000000", "would exceed"},
+ {"Zeros", "", "2, x", "not an integer"},
+ {"Zeros", "", "100000, 100000", "would exceed"},
+ {"Zeros", "", "-1", "ndarray: shape mismatch"},
+ {"Full", "", "", "Full takes a value"},
+ {"Full", "", "x, 2", "not a number"},
+ {"Full", "", "1, x", "not an integer"},
+ {"Full", "", "1, 100000, 100000", "would exceed"},
+ {"Eye", "", "3, 3", "Eye takes n, m, k"},
+ {"Eye", "", "3, x, 0", "not an integer"},
+ {"Eye", "", "100000, 100000, 0", "would exceed"},
+ {"FromData", "", "", "FromData takes"},
+ {"FromData", "", "1, 2", "bracketed list"},
+ {"FromData", "", "[1, x]", "not a number"},
+ {"FromData", "", "[1, 2], x", "not an integer"},
+ {"FromData", "", "[1, 2, 3], 2, 2", "ndarray: shape mismatch"},
+ {"Reshape", "b", "", "Reshape takes the new shape"},
+ {"Reshape", "b", "x", "not an integer"},
+ {"Reshape", "b", "4, 4", "ndarray: shape mismatch"},
+ {"Transpose", "b", "1", "Transpose takes no arguments"},
+ {"Exp", "b", "1", "Exp takes no arguments"},
+ {"Slice", "b", "", "Slice takes one index per axis"},
+ {"Slice", "b", "x", "want an integer or a start:stop:step"},
+ {"Slice", "b", "1:2:3:4", "too many colons"},
+ {"Slice", "b", "1:x", "not an integer"},
+ {"Slice", "b", "5", "ndarray: index out of range"},
+ {"Slice", "b", "::0", "ndarray: index out of range"},
+ {"Squeeze", "b", "x", "not an integer"},
+ {"Squeeze", "b", "0", "ndarray"},
+ {"ExpandDims", "b", "", "ExpandDims takes one axis"},
+ {"ExpandDims", "b", "x", "not an integer"},
+ {"Power", "b", "", "Power takes one exponent"},
+ {"Power", "b", "x", "not a number"},
+ {"Clip", "b", "1", "Clip takes lo, hi"},
+ {"Clip", "b", "x, 1", "not a number"},
+ {"Clip", "b", "1, x", "not a number"},
+ {"Sum", "b", "x", "not an integer"},
+ {"Sum", "b", "0, 1", "can only be keepdims"},
+ {"Sum", "b", "0, keepdims, 1", "at most an axis and keepdims"},
+ {"Sum", "b", "5", "ndarray: axis out of range"},
+ {"Mean", "b", "x, keepdims", "not an integer"},
+ {"CumSum", "b", "x", "not an integer"},
+ {"CumSum", "b", "0, 1", "at most one axis"},
+ {"CumSum", "b", "7", "ndarray: axis out of range"},
+ {"Add", "b", "", "Add takes one operand"},
+ {"Add", "b", "1x", "neither a step name nor a number"},
+ {"Add", "b", "zz", `no step named "zz"`},
+ {"Add", "b", "a", "ndarray: shapes are not broadcastable"},
+ {"Maximum", "b", "1", "is not a step name"},
+ {"MatMul", "b", "b", "ndarray: incompatible shapes for linear algebra"},
+ {"Outer", "b", "", "Outer takes one operand"},
+ {"Concatenate", "a", "0", "takes the other steps, then an axis"},
+ {"Concatenate", "a", "b, x", "the last argument is the axis"},
+ {"Concatenate", "a", "1, 0", "is not a step name"},
+ {"Concatenate", "a", "zz, 0", `no step named "zz"`},
+ {"Concatenate", "a", "b, 0", "ndarray"},
+ {"Stack", "a", "c, 0", "ndarray"},
+ }
+ for _, c := range cases {
+ s := base(t)
+ n := len(s.Steps)
+ _, err := s.Apply(c.op, c.in, c.args)
+ if err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Errorf("%s(%s) on %q: error %v, want it to contain %q", c.op, c.args, c.in, err, c.want)
+ }
+ if len(s.Steps) != n {
+ t.Errorf("%s(%s): a failed step was added", c.op, c.args)
+ }
+ }
+}
+
+// The size guards bound the results of broadcasting, products and joins
+// before go-ndarray allocates them.
+func TestSizeGuards(t *testing.T) {
+ s := &Session{}
+ mustApply(t, s, "Zeros", "", "4096, 1") // a
+ mustApply(t, s, "Zeros", "", "1, 4096") // b
+ mustApply(t, s, "Zeros", "", "4096") // c
+ mustApply(t, s, "Zeros", "", "2048, 2048") // d
+ for _, c := range []struct{ op, in, args string }{
+ {"Add", "a", "b"}, {"Maximum", "a", "b"}, {"MatMul", "a", "b"}, {"Dot", "a", "b"},
+ {"Outer", "c", "c"}, {"Concatenate", "d", "d, 0"},
+ } {
+ if _, err := s.Apply(c.op, c.in, c.args); !errors.Is(err, errTooBig) {
+ t.Errorf("%s %s %s: %v, want the size guard", c.in, c.op, c.args, err)
+ }
+ }
+ if broadcastSize([]int{2, 3}, []int{4}) != nil {
+ t.Error("broadcastSize of incompatible shapes must be nil")
+ }
+ if got := broadcastSize([]int{3}, []int{2, 1}); len(got) != 2 || got[0] != 2 || got[1] != 3 {
+ t.Errorf("broadcastSize((3,), (2, 1)) = %v", got)
+ }
+ if checkSize([]int{0, 1 << 40}) != nil {
+ t.Error("an empty shape is never too big")
+ }
+ if checkSize(nil) != nil {
+ t.Error("a 0-d shape is never too big")
+ }
+ // A zero step leaves the count to go-ndarray.
+ if _, err := s.Apply("Arange", "", "0, 1, 0"); err == nil {
+ t.Error("Arange with step 0 must fail")
+ }
+}
+
+// A panic inside go-ndarray is an error, not a dead tab.
+func TestTimedRecoversAPanic(t *testing.T) {
+ var s Session
+ _, _, _, err := s.timed(func() (*ndarray.Array, error) { panic("boom") })
+ if err == nil || !strings.Contains(err.Error(), "go-ndarray panicked: boom") {
+ t.Fatalf("got %v", err)
+ }
+}
+
+// Timing repeats a fast call to the budget and reports the mean.
+func TestTiming(t *testing.T) {
+ now := time.Unix(0, 0)
+ s := &Session{Clock: func() time.Time { now = now.Add(100 * time.Microsecond); return now }}
+ st := mustApply(t, s, "Arange", "", "0, 3, 1")
+ // Each call is bracketed by two clock reads, 100 µs apart.
+ if st.Runs != 20 || st.Elapsed != 100*time.Microsecond {
+ t.Fatalf("runs %d, mean %v; want 20 runs of 100µs", st.Runs, st.Elapsed)
+ }
+ s.Clock = func() time.Time { now = now.Add(time.Second); return now }
+ st = mustApply(t, s, "Arange", "", "0, 3, 1")
+ if st.Runs != 1 || st.Elapsed != time.Second {
+ t.Fatalf("a slow call must run once: %d runs, %v", st.Runs, st.Elapsed)
+ }
+ var real Session
+ if real.now().IsZero() {
+ t.Fatal("the default clock is time.Now")
+ }
+}
+
+// Views share memory with what they view and copies do not, as NumPy's
+// np.shares_memory says; strides and offsets are go-ndarray's own.
+func TestLayout(t *testing.T) {
+ s := &Session{}
+ mustApply(t, s, "Arange", "", "0, 24, 1")
+ b := mustApply(t, s, "Reshape", "a", "4, 6")
+ c := mustApply(t, s, "Transpose", "b", "")
+ d := mustApply(t, s, "Slice", "b", "1:3, ::2")
+ e := mustApply(t, s, "Copy", "d", "")
+ if strings.Join(b.Shares, ",") != "a" || strings.Join(c.Shares, ",") != "a,b" || len(e.Shares) != 0 {
+ t.Errorf("shares: b %v c %v e %v", b.Shares, c.Shares, e.Shares)
+ }
+ if !b.Layout.Contiguous() || c.Layout.Contiguous() || d.Layout.Contiguous() || !e.Layout.Contiguous() {
+ t.Error("contiguity is wrong")
+ }
+ if ShapeText(d.Layout.Strides) != "(6, 2)" || d.Layout.Offset != 6 || ShapeText(d.Layout.ByteStrides()) != "(48, 16)" {
+ t.Errorf("d strides %v offset %d", d.Layout.Strides, d.Layout.Offset)
+ }
+ // Axes of length one do not break contiguity whatever their stride.
+ if !(Layout{Shape: []int{4, 1}, Strides: []int{1, 1}}).Contiguous() {
+ t.Error("(4,1) with strides (1,1) is contiguous")
+ }
+ if l := LayoutOf(nil); l.Shape != nil || SharesMemory(l, b.Layout) {
+ t.Error("a nil array has no layout and shares nothing")
+ }
+ empty, _ := ndarray.Zeros(0)
+ if SharesMemory(LayoutOf(empty), LayoutOf(empty)) {
+ t.Error("an empty array has no storage to share")
+ }
+}
+
+// The reflect read of go-ndarray's unexported fields must keep working: if
+// the library renames strides, offset or data this fails here rather than
+// leaving the page to show a guess.
+func TestLayoutReadsTheLibrary(t *testing.T) {
+ a, _ := ndarray.Arange(0, 12, 1)
+ m, _ := a.Reshape(3, 4)
+ v, _ := m.Slice(ndarray.R(1, 3), ndarray.Rng(3, 0, -2))
+ l := LayoutOf(v)
+ if ShapeText(l.Strides) != "(4, -2)" || l.Offset != 7 {
+ t.Fatalf("strides %v offset %d, want (4, -2) and 7", l.Strides, l.Offset)
+ }
+ if !SharesMemory(l, LayoutOf(a)) {
+ t.Fatal("a slice of a reshape of a must share a's memory")
+ }
+}
+
+func TestGrid(t *testing.T) {
+ s := &Session{}
+ mustApply(t, s, "Arange", "", "0, 24, 1")
+ g := GridOf(mustApply(t, s, "Reshape", "a", "2, 3, 4").Result)
+ if g.Rows != 3 || g.Cols != 4 || g.Values[2][3] != 11 || !strings.Contains(g.Note, "index 0 of every leading axis") {
+ t.Errorf("3-D grid: %+v", g)
+ }
+ g = GridOf(s.Steps[0].Result)
+ if g.Rows != 1 || g.Cols != 24 || g.Min != 0 || g.Max != 23 || g.Note != "" {
+ t.Errorf("1-D grid: rows %d cols %d min %v max %v note %q", g.Rows, g.Cols, g.Min, g.Max, g.Note)
+ }
+ g = GridOf(mustApply(t, s, "Sum", "a", "").Result)
+ if g.Rows != 1 || g.Cols != 1 || g.Values[0][0] != 276 {
+ t.Errorf("0-d grid: %+v", g)
+ }
+ mustApply(t, s, "Zeros", "", "300, 70")
+ g = GridOf(s.Steps[len(s.Steps)-1].Result)
+ if len(g.Values) != GridMaxRows || len(g.Values[0]) != GridMaxCols || !strings.HasPrefix(g.Note, "showing the first 200 × 64") {
+ t.Errorf("big grid: %d x %d, note %q", len(g.Values), len(g.Values[0]), g.Note)
+ }
+ mustApply(t, s, "Zeros", "", "2, 300, 70")
+ g = GridOf(s.Steps[len(s.Steps)-1].Result)
+ if !strings.Contains(g.Note, "leading axis; first 200 × 64") {
+ t.Errorf("big 3-D grid note %q", g.Note)
+ }
+ mustApply(t, s, "Linspace", "", "-1, 1, 3")
+ g = GridOf(mustApply(t, s, "Log", "f", "").Result)
+ if !math.IsNaN(g.Values[0][0]) || g.Min != 0 || g.Max != 0 {
+ t.Errorf("non-finite values are left out of the range: %+v", g)
+ }
+ if g := GridOf(nil); g.Values != nil || g.Min <= g.Max {
+ t.Error("nil grid must be empty with an empty range")
+ }
+ mustApply(t, s, "Zeros", "", "0, 3")
+ if g := GridOf(s.Steps[len(s.Steps)-1].Result); g.Values != nil || g.Note != "the array is empty" {
+ t.Errorf("empty grid %+v", g)
+ }
+ if FormatValue(1.0/3) != "0.333333" {
+ t.Error(FormatValue(1.0 / 3))
+ }
+}
+
+func TestProgramsAndSession(t *testing.T) {
+ var s Session
+ if !strings.HasPrefix(s.GoProgram(), "//") || !strings.HasPrefix(s.PyProgram(), "#") {
+ t.Error("an empty pipeline has placeholder programs")
+ }
+ if err := s.Load(Presets[0]); err != nil {
+ t.Fatal(err)
+ }
+ g := s.GoProgram()
+ for _, want := range []string{"package main", "\ta, err := ndarray.Arange(0, 6, 1)\n\tcheck(err)\n", "\td := b.Transpose()\n\te, err", `fmt.Println("g =", g)`, "func check(err error)"} {
+ if !strings.Contains(g, want) {
+ t.Errorf("Go program lacks %q:\n%s", want, g)
+ }
+ }
+ p := s.PyProgram()
+ for _, want := range []string{"import numpy as np", "d = b.T\n", `print("g =", g)`} {
+ if !strings.Contains(p, want) {
+ t.Errorf("NumPy program lacks %q:\n%s", want, p)
+ }
+ }
+ if strings.Join(s.Names(), "") != "abcdefg" {
+ t.Error(s.Names())
+ }
+ s.RemoveLast()
+ if len(s.Steps) != 6 {
+ t.Error("RemoveLast")
+ }
+ s.Reset()
+ s.RemoveLast()
+ if len(s.Steps) != 0 {
+ t.Error("Reset")
+ }
+ if err := s.Load(Preset{Calls: []Call{{"Arange", "", "0, 1, 1"}, {"Reshape", "a", "5"}}}); err == nil || len(s.Steps) != 1 {
+ t.Errorf("Load stops at the first failing call: %v, %d steps", err, len(s.Steps))
+ }
+ // Every preset loads, and its proposed next step does what it says.
+ for _, p := range Presets {
+ if err := s.Load(p); err != nil {
+ t.Errorf("%s: %v", p.Name, err)
+ }
+ if p.Next != nil {
+ if _, err := s.Apply(p.Next.Op, p.Next.Input, p.Next.Args); err == nil {
+ t.Errorf("%s: the proposed step should show an error", p.Name)
+ }
+ }
+ }
+ if _, ok := OpByName("Nope"); ok {
+ t.Error("OpByName")
+ }
+}
+
+func TestHelpers(t *testing.T) {
+ if splitArgs(" ") != nil || len(splitArgs("a, [1, 2], (3, 4)")) != 3 {
+ t.Error("splitArgs")
+ }
+ if v, err := parseFloat(" -inf "); err != nil || !math.IsInf(v, -1) {
+ t.Error("parseFloat inf")
+ }
+ if _, err := parseInts([]string{"(2, x)"}); err == nil {
+ t.Error("parseInts must refuse x")
+ }
+ if pyTuple([]int{3}) != "(3,)" || pyTuple([]int{2, 3}) != "(2, 3)" || ShapeText(nil) != "()" {
+ t.Error("tuples")
+ }
+ for _, n := range []string{"", "A", "a-b"} {
+ if isName(n) {
+ t.Errorf("%q is not a name", n)
+ }
+ }
+ if !isName("a1") || StepName(0) != "a" || StepName(25) != "z" || StepName(26) != "a1" || StepName(53) != "b2" {
+ t.Error("names")
+ }
+}
diff --git a/playground/engine/goroutines_test.go b/playground/engine/goroutines_test.go
new file mode 100644
index 0000000..4a24a4a
--- /dev/null
+++ b/playground/engine/goroutines_test.go
@@ -0,0 +1,51 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package engine
+
+import (
+ "runtime"
+ "testing"
+)
+
+// bigSteps runs operations over 10^6 elements, far past go-ndarray's
+// parallel threshold (32768): elementwise, a ufunc, reductions, a GEMM.
+func bigSteps(t *testing.T) {
+ t.Helper()
+ s := &Session{}
+ mustApply(t, s, "Arange", "", "0, 1000000, 1")
+ mustApply(t, s, "Exp", "a", "")
+ mustApply(t, s, "Sum", "b", "")
+ mustApply(t, s, "Reshape", "a", "1000, 1000")
+ mustApply(t, s, "Sum", "d", "1")
+ mustApply(t, s, "MatMul", "d", "d")
+}
+
+// TestHelperGoroutines says what go-ndarray's persistent helper pool (README,
+// "Goroutines": up to GOMAXPROCS-1 helpers, kept alive) does where the
+// playground runs. Under GOOS=js the Go runtime has one P — GOMAXPROCS is 1 —
+// so every operation takes go-ndarray's serial path and no helper is ever
+// started: the goroutine count is the same after 10^6-element operations as
+// before. Natively, with GOMAXPROCS 4, the same operations DO start helpers,
+// which is the positive control: this instrument can see them.
+func TestHelperGoroutines(t *testing.T) {
+ if runtime.GOOS == "js" {
+ if p := runtime.GOMAXPROCS(0); p != 1 {
+ t.Fatalf("GOMAXPROCS under js/wasm is %d, want 1", p)
+ }
+ before := runtime.NumGoroutine()
+ bigSteps(t)
+ if after := runtime.NumGoroutine(); after != before {
+ t.Fatalf("goroutines %d -> %d: go-ndarray started helpers under js/wasm", before, after)
+ }
+ return
+ }
+ defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(4))
+ before := runtime.NumGoroutine()
+ bigSteps(t)
+ after := runtime.NumGoroutine()
+ if after < before+1 && after < 4 {
+ t.Fatalf("goroutines %d -> %d with GOMAXPROCS 4: the instrument does not see go-ndarray's helpers", before, after)
+ }
+ t.Logf("native, GOMAXPROCS 4: goroutines %d -> %d", before, after)
+}
diff --git a/playground/engine/inspect.go b/playground/engine/inspect.go
new file mode 100644
index 0000000..d9055bb
--- /dev/null
+++ b/playground/engine/inspect.go
@@ -0,0 +1,176 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package engine
+
+import (
+ "math"
+ "reflect"
+ "strconv"
+
+ "github.com/go-ndarray/ndarray"
+)
+
+// Layout is how an array sits in memory, read from go-ndarray's own Array
+// value: its shape, its strides and offset (in elements, the unit go-ndarray
+// stores them in), and the span of the storage it points into.
+//
+// go-ndarray v0.7.1 exports neither the strides nor the storage, so Layout
+// reads the unexported fields through reflect — read-only, and without
+// unsafe. This is the one place the playground depends on go-ndarray's
+// internals; TestLayoutReadsTheLibrary fails if the fields are renamed, so a
+// library change cannot silently turn the panel into a guess.
+type Layout struct {
+ Shape []int
+ Strides []int // in elements
+ Offset int // in elements, from the start of the storage
+ // base and end bound the storage the array points into (its backing
+ // slice, start to capacity); two arrays share memory when these overlap.
+ base, end uintptr
+}
+
+// LayoutOf reads a's layout. A nil array has the zero Layout.
+func LayoutOf(a *ndarray.Array) Layout {
+ if a == nil {
+ return Layout{}
+ }
+ v := reflect.ValueOf(a).Elem()
+ l := Layout{Shape: a.Shape()}
+ st := v.FieldByName("strides")
+ l.Strides = make([]int, st.Len())
+ for i := range l.Strides {
+ l.Strides[i] = int(st.Index(i).Int())
+ }
+ l.Offset = int(v.FieldByName("offset").Int())
+ data := v.FieldByName("data")
+ if data.Cap() > 0 {
+ l.base = data.Pointer()
+ l.end = l.base + uintptr(data.Cap())*8
+ }
+ return l
+}
+
+// SharesMemory reports whether two layouts point into overlapping storage,
+// i.e. whether writing through one could change the other — NumPy's
+// np.shares_memory, answered from the storage spans.
+func SharesMemory(x, y Layout) bool {
+ if x.base == 0 || y.base == 0 {
+ return false
+ }
+ return x.base < y.end && y.base < x.end
+}
+
+// Contiguous reports whether the layout is C-contiguous: its strides are the
+// row-major strides of its shape (axes of length 1 may have any stride).
+func (l Layout) Contiguous() bool {
+ want := 1
+ for i := len(l.Shape) - 1; i >= 0; i-- {
+ if l.Shape[i] != 1 && l.Strides[i] != want {
+ return false
+ }
+ want *= l.Shape[i]
+ }
+ return true
+}
+
+// ByteStrides is Strides in bytes, the unit NumPy's .strides reports.
+func (l Layout) ByteStrides() []int {
+ out := make([]int, len(l.Strides))
+ for i, s := range l.Strides {
+ out[i] = s * 8
+ }
+ return out
+}
+
+// ShapeText writes a shape as NumPy prints it: "(2, 3)", "(3,)", "()".
+func ShapeText(shape []int) string {
+ if len(shape) == 0 {
+ return "()"
+ }
+ return pyTuple(shape)
+}
+
+// Grid is the part of an array the value table shows: a 2-D window of it.
+// A 0-d or 1-D array is one row; an array of more than two axes shows the
+// last two axes at index 0 of every leading axis, and Note says so.
+type Grid struct {
+ Values [][]float64
+ RowLabels []string
+ ColLabels []string
+ Rows int // rows of the whole 2-D view (Values may hold fewer)
+ Cols int
+ Note string
+ Min, Max float64 // over the finite shown values; Min > Max when none
+}
+
+// Grid limits: past these the table would only be scrolled, never read.
+const (
+ GridMaxRows = 200
+ GridMaxCols = 64
+)
+
+// GridOf reads the window of a the table shows.
+func GridOf(a *ndarray.Array) Grid {
+ g := Grid{Min: math.Inf(1), Max: math.Inf(-1)}
+ if a == nil {
+ return g
+ }
+ shape := a.Shape()
+ if a.Size() == 0 {
+ g.Note = "the array is empty"
+ return g
+ }
+ lead := make([]int, 0, len(shape))
+ rows, cols := 1, 1
+ switch len(shape) {
+ case 0:
+ case 1:
+ cols = shape[0]
+ default:
+ for range shape[:len(shape)-2] {
+ lead = append(lead, 0)
+ }
+ rows, cols = shape[len(shape)-2], shape[len(shape)-1]
+ if len(shape) > 2 {
+ g.Note = "showing index 0 of every leading axis"
+ }
+ }
+ g.Rows, g.Cols = rows, cols
+ shownR, shownC := min(rows, GridMaxRows), min(cols, GridMaxCols)
+ if (rows > shownR || cols > shownC) && g.Note == "" {
+ g.Note = "showing the first " + strconv.Itoa(shownR) + " × " + strconv.Itoa(shownC)
+ } else if rows > shownR || cols > shownC {
+ g.Note += "; first " + strconv.Itoa(shownR) + " × " + strconv.Itoa(shownC)
+ }
+ for j := 0; j < shownC; j++ {
+ g.ColLabels = append(g.ColLabels, strconv.Itoa(j))
+ }
+ idx := make([]int, len(shape))
+ copy(idx, lead)
+ for i := 0; i < shownR; i++ {
+ row := make([]float64, shownC)
+ for j := 0; j < shownC; j++ {
+ switch len(shape) {
+ case 0:
+ case 1:
+ idx[0] = j
+ default:
+ idx[len(shape)-2], idx[len(shape)-1] = i, j
+ }
+ v := a.At(idx...)
+ row[j] = v
+ if !math.IsNaN(v) && !math.IsInf(v, 0) {
+ g.Min = math.Min(g.Min, v)
+ g.Max = math.Max(g.Max, v)
+ }
+ }
+ g.Values = append(g.Values, row)
+ g.RowLabels = append(g.RowLabels, strconv.Itoa(i))
+ }
+ return g
+}
+
+// FormatValue writes one value for a table cell: six significant digits.
+func FormatValue(v float64) string {
+ return strconv.FormatFloat(v, 'g', 6, 64)
+}
diff --git a/playground/engine/ops.go b/playground/engine/ops.go
new file mode 100644
index 0000000..bdc9624
--- /dev/null
+++ b/playground/engine/ops.go
@@ -0,0 +1,797 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package engine
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/go-ndarray/ndarray"
+)
+
+// Kind says what an operation takes.
+type Kind int
+
+const (
+ // KindCreate builds a new array from its arguments alone.
+ KindCreate Kind = iota
+ // KindUnary acts on one input array (a view, a ufunc, a reduction).
+ KindUnary
+ // KindBinary combines the input array with one operand: another step, or
+ // for the four arithmetic operations a number.
+ KindBinary
+ // KindJoin joins the input array with other steps along an axis.
+ KindJoin
+)
+
+// MaxElements bounds the size of an array the playground will build. A
+// browser tab has a few GiB at most, and Go cannot recover from running out
+// of memory: the tab would die instead of showing an error. 2^22 float64s are
+// 32 MiB — far beyond anything worth looking at in a table.
+const MaxElements = 1 << 22
+
+// errTooBig is returned for a result over MaxElements, before it is built.
+var errTooBig = fmt.Errorf("playground: result would exceed %d elements; the browser tab would run out of memory", MaxElements)
+
+// Op is one operation of the playground's catalog.
+type Op struct {
+ Name string // the go-ndarray name, as listed in the operation menu
+ Kind Kind
+ Hint string // what the argument line holds
+ Default string // a sensible argument line to start from
+ build func(c call) (plan, error)
+}
+
+// call is what an operation is built from: its input (nil for KindCreate),
+// the input's step name, the split argument line, and a lookup for the other
+// steps an argument names.
+type call struct {
+ in *ndarray.Array
+ inName string
+ args []string
+ lookup func(name string) (*ndarray.Array, error)
+}
+
+// plan is a built operation: the computation, ready to run (and re-run, for
+// timing), and the Go and NumPy spellings of it.
+type plan struct {
+ run func() (*ndarray.Array, error)
+ scalar bool // the Go call returns a float64 or an int, not an *Array
+ goRHS string // the Go expression
+ goErr bool // the Go expression also returns an error
+ py string // the NumPy expression
+}
+
+// Ops is the catalog, in menu order.
+var Ops = []Op{
+ {Name: "Arange", Kind: KindCreate, Hint: "start, stop, step", Default: "0, 12, 1", build: buildArange},
+ {Name: "Linspace", Kind: KindCreate, Hint: "start, stop, num", Default: "0, 1, 5", build: buildLinspace},
+ {Name: "Zeros", Kind: KindCreate, Hint: "shape, e.g. 2, 3", Default: "2, 3", build: buildFilled("Zeros", "zeros")},
+ {Name: "Ones", Kind: KindCreate, Hint: "shape, e.g. 2, 3", Default: "2, 3", build: buildFilled("Ones", "ones")},
+ {Name: "Full", Kind: KindCreate, Hint: "value, shape…", Default: "7, 2, 3", build: buildFull},
+ {Name: "Eye", Kind: KindCreate, Hint: "n, m, k (diagonal)", Default: "4, 4, 0", build: buildEye},
+ {Name: "FromData", Kind: KindCreate, Hint: "[values], shape…", Default: "[1, 2, 3, 4, 5, 6], 2, 3", build: buildFromData},
+
+ {Name: "Reshape", Kind: KindUnary, Hint: "new shape, -1 is inferred", Default: "3, -1", build: buildReshape},
+ {Name: "Transpose", Kind: KindUnary, Hint: "(no arguments)", build: buildMethod("Transpose", ".T")},
+ {Name: "Slice", Kind: KindUnary, Hint: "NumPy indices: 1:3, ::2, 0, -1, :", Default: ":, 1:", build: buildSlice},
+ {Name: "Flatten", Kind: KindUnary, Hint: "(no arguments)", build: buildMethod("Flatten", ".flatten()")},
+ {Name: "Copy", Kind: KindUnary, Hint: "(no arguments)", build: buildMethod("Copy", ".copy()")},
+ {Name: "Squeeze", Kind: KindUnary, Hint: "axes to drop (none = every length-1 axis)", build: buildSqueeze},
+ {Name: "ExpandDims", Kind: KindUnary, Hint: "axis", Default: "0", build: buildExpandDims},
+
+ {Name: "Exp", Kind: KindUnary, Hint: "(no arguments)", build: buildUfunc("Exp", "exp")},
+ {Name: "Log", Kind: KindUnary, Hint: "(no arguments)", build: buildUfunc("Log", "log")},
+ {Name: "Sqrt", Kind: KindUnary, Hint: "(no arguments)", build: buildUfunc("Sqrt", "sqrt")},
+ {Name: "Sin", Kind: KindUnary, Hint: "(no arguments)", build: buildUfunc("Sin", "sin")},
+ {Name: "Cos", Kind: KindUnary, Hint: "(no arguments)", build: buildUfunc("Cos", "cos")},
+ {Name: "Abs", Kind: KindUnary, Hint: "(no arguments)", build: buildUfunc("Abs", "abs")},
+ {Name: "Square", Kind: KindUnary, Hint: "(no arguments)", build: buildUfunc("Square", "square")},
+ {Name: "Power", Kind: KindUnary, Hint: "exponent", Default: "2", build: buildPower},
+ {Name: "Clip", Kind: KindUnary, Hint: "lo, hi", Default: "2, 8", build: buildClip},
+
+ {Name: "Sum", Kind: KindUnary, Hint: "axis[, keepdims] (none = whole array)", Default: "0", build: buildReduce("Sum", "sum")},
+ {Name: "Mean", Kind: KindUnary, Hint: "axis[, keepdims] (none = whole array)", Default: "0", build: buildReduce("Mean", "mean")},
+ {Name: "Max", Kind: KindUnary, Hint: "axis[, keepdims] (none = whole array)", Default: "1", build: buildReduce("Max", "max")},
+ {Name: "Min", Kind: KindUnary, Hint: "axis[, keepdims] (none = whole array)", Default: "1", build: buildReduce("Min", "min")},
+ {Name: "ArgMax", Kind: KindUnary, Hint: "axis[, keepdims] (none = flat index)", Default: "1", build: buildReduce("ArgMax", "argmax")},
+ {Name: "CumSum", Kind: KindUnary, Hint: "axis (none = flattened)", Default: "0", build: buildCumSum},
+
+ {Name: "Add", Kind: KindBinary, Hint: "a step name, or a number", Default: "10", build: buildArith("Add", "+")},
+ {Name: "Sub", Kind: KindBinary, Hint: "a step name, or a number", Default: "1", build: buildArith("Sub", "-")},
+ {Name: "Mul", Kind: KindBinary, Hint: "a step name, or a number", Default: "2", build: buildArith("Mul", "*")},
+ {Name: "Div", Kind: KindBinary, Hint: "a step name, or a number", Default: "2", build: buildArith("Div", "/")},
+ {Name: "Maximum", Kind: KindBinary, Hint: "a step name", build: buildArrayBinary("Maximum", "np.maximum(%s, %s)")},
+ {Name: "Minimum", Kind: KindBinary, Hint: "a step name", build: buildArrayBinary("Minimum", "np.minimum(%s, %s)")},
+ {Name: "MatMul", Kind: KindBinary, Hint: "a step name", build: buildArrayBinary("MatMul", "%s @ %s")},
+ {Name: "Dot", Kind: KindBinary, Hint: "a step name", build: buildArrayBinary("Dot", "np.dot(%s, %s)")},
+ {Name: "Outer", Kind: KindBinary, Hint: "a step name", build: buildOuter},
+
+ {Name: "Concatenate", Kind: KindJoin, Hint: "other steps…, axis", build: buildJoin("Concatenate", "concatenate")},
+ {Name: "Stack", Kind: KindJoin, Hint: "other steps…, axis", build: buildJoin("Stack", "stack")},
+}
+
+// OpByName finds an operation of the catalog.
+func OpByName(name string) (Op, bool) {
+ for _, op := range Ops {
+ if op.Name == name {
+ return op, true
+ }
+ }
+ return Op{}, false
+}
+
+// --- helpers -----------------------------------------------------------------
+
+// noArgs refuses an argument line on an operation that takes none.
+func noArgs(name string, c call) error {
+ if len(c.args) != 0 {
+ return fmt.Errorf("%s takes no arguments", name)
+ }
+ return nil
+}
+
+// checkSize refuses a shape whose element count exceeds MaxElements (or is
+// not representable). Negative dimensions are left to go-ndarray, so the
+// visitor sees its own error for them.
+func checkSize(shape []int) error {
+ n := 1
+ for _, d := range shape {
+ if d <= 0 {
+ return nil
+ }
+ if n > MaxElements/d {
+ return errTooBig
+ }
+ n *= d
+ }
+ return nil
+}
+
+// scalarArray wraps a reduction's number as a 0-d array, so it can be shown
+// like any other result.
+func scalarArray(v float64) *ndarray.Array {
+ a, _ := ndarray.FromData([]float64{v})
+ return a
+}
+
+// --- creation ----------------------------------------------------------------
+
+func buildArange(c call) (plan, error) {
+ if len(c.args) != 3 {
+ return plan{}, errors.New("Arange takes start, stop, step")
+ }
+ var v [3]float64
+ for i, a := range c.args {
+ f, err := parseFloat(a)
+ if err != nil {
+ return plan{}, err
+ }
+ v[i] = f
+ }
+ if v[2] != 0 {
+ if n := (v[1] - v[0]) / v[2]; n > MaxElements {
+ return plan{}, errTooBig
+ }
+ }
+ args := formatFloat(v[0]) + ", " + formatFloat(v[1]) + ", " + formatFloat(v[2])
+ return plan{
+ run: func() (*ndarray.Array, error) { return ndarray.Arange(v[0], v[1], v[2]) },
+ goRHS: "ndarray.Arange(" + args + ")", goErr: true,
+ py: "np.arange(" + args + ")",
+ }, nil
+}
+
+func buildLinspace(c call) (plan, error) {
+ if len(c.args) != 3 {
+ return plan{}, errors.New("Linspace takes start, stop, num")
+ }
+ start, err := parseFloat(c.args[0])
+ if err != nil {
+ return plan{}, err
+ }
+ stop, err := parseFloat(c.args[1])
+ if err != nil {
+ return plan{}, err
+ }
+ num, err := parseInt(c.args[2])
+ if err != nil {
+ return plan{}, err
+ }
+ if err := checkSize([]int{num}); err != nil {
+ return plan{}, err
+ }
+ args := formatFloat(start) + ", " + formatFloat(stop) + ", " + strconv.Itoa(num)
+ return plan{
+ run: func() (*ndarray.Array, error) { return ndarray.Linspace(start, stop, num) },
+ goRHS: "ndarray.Linspace(" + args + ")", goErr: true,
+ py: "np.linspace(" + args + ")",
+ }, nil
+}
+
+func buildFilled(goName, pyName string) func(c call) (plan, error) {
+ return func(c call) (plan, error) {
+ shape, err := parseInts(c.args)
+ if err != nil {
+ return plan{}, err
+ }
+ if err := checkSize(shape); err != nil {
+ return plan{}, err
+ }
+ f := ndarray.Zeros
+ if goName == "Ones" {
+ f = ndarray.Ones
+ }
+ return plan{
+ run: func() (*ndarray.Array, error) { return f(shape...) },
+ goRHS: "ndarray." + goName + "(" + joinInts(shape) + ")", goErr: true,
+ py: "np." + pyName + "(" + pyTuple(shape) + ")",
+ }, nil
+ }
+}
+
+func buildFull(c call) (plan, error) {
+ if len(c.args) < 1 {
+ return plan{}, errors.New("Full takes a value, then the shape")
+ }
+ v, err := parseFloat(c.args[0])
+ if err != nil {
+ return plan{}, err
+ }
+ shape, err := parseInts(c.args[1:])
+ if err != nil {
+ return plan{}, err
+ }
+ if err := checkSize(shape); err != nil {
+ return plan{}, err
+ }
+ goArgs := formatFloat(v)
+ if len(shape) > 0 {
+ goArgs += ", " + joinInts(shape)
+ }
+ py := "np.full(" + pyTuple(shape) + ", " + formatFloat(v) + ")"
+ if len(shape) == 0 {
+ py = "np.full((), " + formatFloat(v) + ")"
+ }
+ return plan{
+ run: func() (*ndarray.Array, error) { return ndarray.Full(v, shape...) },
+ goRHS: "ndarray.Full(" + goArgs + ")", goErr: true,
+ py: py,
+ }, nil
+}
+
+func buildEye(c call) (plan, error) {
+ v, err := parseInts(c.args)
+ if err != nil {
+ return plan{}, err
+ }
+ if len(v) != 3 {
+ return plan{}, errors.New("Eye takes n, m, k")
+ }
+ if err := checkSize(v[:2]); err != nil {
+ return plan{}, err
+ }
+ return plan{
+ run: func() (*ndarray.Array, error) { return ndarray.Eye(v[0], v[1], v[2]) },
+ goRHS: "ndarray.Eye(" + joinInts(v) + ")", goErr: true,
+ py: "np.eye(" + joinInts(v) + ")",
+ }, nil
+}
+
+func buildFromData(c call) (plan, error) {
+ if len(c.args) < 1 {
+ return plan{}, errors.New("FromData takes [values], then the shape")
+ }
+ data, err := parseFloatList(c.args[0])
+ if err != nil {
+ return plan{}, err
+ }
+ shape, err := parseInts(c.args[1:])
+ if err != nil {
+ return plan{}, err
+ }
+ if len(shape) == 0 {
+ shape = []int{len(data)}
+ }
+ vals := make([]string, len(data))
+ for i, v := range data {
+ vals[i] = formatFloat(v)
+ }
+ list := strings.Join(vals, ", ")
+ py := "np.array([" + list + "])"
+ if len(shape) != 1 || shape[0] != len(data) {
+ py += ".reshape(" + joinInts(shape) + ")"
+ }
+ return plan{
+ run: func() (*ndarray.Array, error) {
+ // FromData keeps the slice it is given; hand each run its own so a
+ // timing loop never aliases one array's storage into the next.
+ return ndarray.FromData(append([]float64(nil), data...), shape...)
+ },
+ goRHS: "ndarray.FromData([]float64{" + list + "}, " + joinInts(shape) + ")", goErr: true,
+ py: py,
+ }, nil
+}
+
+// --- views -------------------------------------------------------------------
+
+func buildReshape(c call) (plan, error) {
+ shape, err := parseInts(c.args)
+ if err != nil {
+ return plan{}, err
+ }
+ if len(shape) == 0 {
+ return plan{}, errors.New("Reshape takes the new shape")
+ }
+ in := c.in
+ return plan{
+ run: func() (*ndarray.Array, error) { return in.Reshape(shape...) },
+ goRHS: c.inName + ".Reshape(" + joinInts(shape) + ")", goErr: true,
+ py: c.inName + ".reshape(" + joinInts(shape) + ")",
+ }, nil
+}
+
+// buildMethod is an argument-less method returning *Array and no error.
+func buildMethod(goName, pySuffix string) func(c call) (plan, error) {
+ return func(c call) (plan, error) {
+ if err := noArgs(goName, c); err != nil {
+ return plan{}, err
+ }
+ in := c.in
+ var f func() *ndarray.Array
+ switch goName {
+ case "Transpose":
+ f = in.Transpose
+ case "Flatten":
+ f = in.Flatten
+ default:
+ f = in.Copy
+ }
+ return plan{
+ run: func() (*ndarray.Array, error) { return f(), nil },
+ goRHS: c.inName + "." + goName + "()",
+ py: c.inName + pySuffix,
+ }, nil
+ }
+}
+
+// sliceIndex turns one NumPy index token into a go-ndarray Index, its Go
+// spelling and its NumPy spelling. n is the length of the axis it indexes;
+// it is needed only for "a::s" and ":b:s", which go-ndarray spells with both
+// bounds (Rng), so the Go code names the bound NumPy leaves implicit.
+func sliceIndex(tok string, n int) (ndarray.Index, string, error) {
+ if !strings.Contains(tok, ":") {
+ i, err := parseInt(tok)
+ if err != nil {
+ return ndarray.Index{}, "", fmt.Errorf("index %q: want an integer or a start:stop:step slice", tok)
+ }
+ return ndarray.A(i), "ndarray.A(" + strconv.Itoa(i) + ")", nil
+ }
+ parts := strings.Split(tok, ":")
+ if len(parts) > 3 {
+ return ndarray.Index{}, "", fmt.Errorf("index %q: too many colons", tok)
+ }
+ var vals [3]int
+ var has [3]bool
+ for i, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ v, err := parseInt(p)
+ if err != nil {
+ return ndarray.Index{}, "", fmt.Errorf("index %q: %v", tok, err)
+ }
+ vals[i], has[i] = v, true
+ }
+ start, stop, step := vals[0], vals[1], 1
+ if has[2] {
+ step = vals[2]
+ }
+ itoa := strconv.Itoa
+ switch {
+ case !has[0] && !has[1] && step == 1:
+ return ndarray.All(), "ndarray.All()", nil
+ case !has[0] && !has[1]:
+ return ndarray.Step(step), "ndarray.Step(" + itoa(step) + ")", nil
+ case has[0] && has[1] && step == 1:
+ return ndarray.R(start, stop), "ndarray.R(" + itoa(start) + ", " + itoa(stop) + ")", nil
+ case has[0] && !has[1] && step == 1:
+ return ndarray.From(start), "ndarray.From(" + itoa(start) + ")", nil
+ case !has[0] && has[1] && step == 1:
+ return ndarray.To(stop), "ndarray.To(" + itoa(stop) + ")", nil
+ }
+ // A stepped range with a bound left out: name it. NumPy's implicit
+ // bound runs to the end the step walks towards.
+ if !has[0] {
+ start = 0
+ if step < 0 {
+ start = n - 1
+ }
+ }
+ if !has[1] {
+ stop = n
+ if step < 0 {
+ stop = -n - 1
+ }
+ }
+ return ndarray.Rng(start, stop, step),
+ "ndarray.Rng(" + itoa(start) + ", " + itoa(stop) + ", " + itoa(step) + ")", nil
+}
+
+func buildSlice(c call) (plan, error) {
+ if len(c.args) == 0 {
+ return plan{}, errors.New("Slice takes one index per axis, NumPy style: 1:3, ::2, 0")
+ }
+ shape := c.in.Shape()
+ idx := make([]ndarray.Index, len(c.args))
+ goIdx := make([]string, len(c.args))
+ for i, tok := range c.args {
+ n := 0
+ if i < len(shape) {
+ n = shape[i]
+ }
+ ix, g, err := sliceIndex(tok, n)
+ if err != nil {
+ return plan{}, err
+ }
+ idx[i], goIdx[i] = ix, g
+ }
+ in := c.in
+ return plan{
+ run: func() (*ndarray.Array, error) { return in.Slice(idx...) },
+ goRHS: c.inName + ".Slice(" + strings.Join(goIdx, ", ") + ")", goErr: true,
+ py: c.inName + "[" + strings.Join(c.args, ", ") + "]",
+ }, nil
+}
+
+func buildSqueeze(c call) (plan, error) {
+ axes, err := parseInts(c.args)
+ if err != nil {
+ return plan{}, err
+ }
+ in := c.in
+ py := "np.squeeze(" + c.inName + ")"
+ if len(axes) == 1 {
+ py = "np.squeeze(" + c.inName + ", axis=" + strconv.Itoa(axes[0]) + ")"
+ } else if len(axes) > 1 {
+ py = "np.squeeze(" + c.inName + ", axis=" + pyTuple(axes) + ")"
+ }
+ return plan{
+ run: func() (*ndarray.Array, error) { return in.Squeeze(axes...) },
+ goRHS: c.inName + ".Squeeze(" + joinInts(axes) + ")", goErr: true,
+ py: py,
+ }, nil
+}
+
+func buildExpandDims(c call) (plan, error) {
+ v, err := parseInts(c.args)
+ if err != nil {
+ return plan{}, err
+ }
+ if len(v) != 1 {
+ return plan{}, errors.New("ExpandDims takes one axis")
+ }
+ in := c.in
+ return plan{
+ run: func() (*ndarray.Array, error) { return in.ExpandDims(v[0]) },
+ goRHS: c.inName + ".ExpandDims(" + strconv.Itoa(v[0]) + ")", goErr: true,
+ py: "np.expand_dims(" + c.inName + ", " + strconv.Itoa(v[0]) + ")",
+ }, nil
+}
+
+// --- ufuncs ------------------------------------------------------------------
+
+func buildUfunc(goName, pyName string) func(c call) (plan, error) {
+ return func(c call) (plan, error) {
+ if err := noArgs(goName, c); err != nil {
+ return plan{}, err
+ }
+ in := c.in
+ f := map[string]func() *ndarray.Array{
+ "Exp": in.Exp, "Log": in.Log, "Sqrt": in.Sqrt, "Sin": in.Sin,
+ "Cos": in.Cos, "Abs": in.Abs, "Square": in.Square,
+ }[goName]
+ return plan{
+ run: func() (*ndarray.Array, error) { return f(), nil },
+ goRHS: c.inName + "." + goName + "()",
+ py: "np." + pyName + "(" + c.inName + ")",
+ }, nil
+ }
+}
+
+func buildPower(c call) (plan, error) {
+ if len(c.args) != 1 {
+ return plan{}, errors.New("Power takes one exponent")
+ }
+ p, err := parseFloat(c.args[0])
+ if err != nil {
+ return plan{}, err
+ }
+ in := c.in
+ return plan{
+ run: func() (*ndarray.Array, error) { return in.Power(p), nil },
+ goRHS: c.inName + ".Power(" + formatFloat(p) + ")",
+ py: c.inName + " ** " + formatFloat(p),
+ }, nil
+}
+
+func buildClip(c call) (plan, error) {
+ if len(c.args) != 2 {
+ return plan{}, errors.New("Clip takes lo, hi")
+ }
+ lo, err := parseFloat(c.args[0])
+ if err != nil {
+ return plan{}, err
+ }
+ hi, err := parseFloat(c.args[1])
+ if err != nil {
+ return plan{}, err
+ }
+ in := c.in
+ args := formatFloat(lo) + ", " + formatFloat(hi)
+ return plan{
+ run: func() (*ndarray.Array, error) { return in.Clip(lo, hi) },
+ goRHS: c.inName + ".Clip(" + args + ")", goErr: true,
+ py: "np.clip(" + c.inName + ", " + args + ")",
+ }, nil
+}
+
+// --- reductions --------------------------------------------------------------
+
+// axisArgs reads "", "axis" or "axis, keepdims".
+func axisArgs(name string, args []string) (axis int, hasAxis, keep bool, err error) {
+ switch len(args) {
+ case 0:
+ return 0, false, false, nil
+ case 1, 2:
+ axis, err = parseInt(args[0])
+ if err != nil {
+ return 0, false, false, err
+ }
+ if len(args) == 2 {
+ if args[1] != "keepdims" && args[1] != "true" {
+ return 0, false, false, fmt.Errorf("%s: the second argument can only be keepdims", name)
+ }
+ keep = true
+ }
+ return axis, true, keep, nil
+ }
+ return 0, false, false, fmt.Errorf("%s takes at most an axis and keepdims", name)
+}
+
+func buildReduce(goName, pyName string) func(c call) (plan, error) {
+ return func(c call) (plan, error) {
+ axis, hasAxis, keep, err := axisArgs(goName, c.args)
+ if err != nil {
+ return plan{}, err
+ }
+ in, name := c.in, c.inName
+ if !hasAxis {
+ p := plan{scalar: true, goErr: true, py: name + "." + pyName + "()"}
+ p.goRHS = name + "." + goName + "()"
+ switch goName {
+ case "Sum":
+ p.goErr = false
+ p.run = func() (*ndarray.Array, error) { return scalarArray(in.Sum()), nil }
+ case "ArgMax":
+ p.run = func() (*ndarray.Array, error) {
+ i, err := in.ArgMax()
+ return scalarArray(float64(i)), err
+ }
+ default:
+ f := map[string]func() (float64, error){"Mean": in.Mean, "Max": in.Max, "Min": in.Min}[goName]
+ p.run = func() (*ndarray.Array, error) {
+ v, err := f()
+ return scalarArray(v), err
+ }
+ }
+ return p, nil
+ }
+ f := map[string]func(int, bool) (*ndarray.Array, error){
+ "Sum": in.SumAxis, "Mean": in.MeanAxis, "Max": in.MaxAxis,
+ "Min": in.MinAxis, "ArgMax": in.ArgMaxAxis,
+ }[goName]
+ py := name + "." + pyName + "(axis=" + strconv.Itoa(axis)
+ if keep {
+ py += ", keepdims=True"
+ }
+ return plan{
+ run: func() (*ndarray.Array, error) { return f(axis, keep) },
+ goRHS: name + "." + goName + "Axis(" + strconv.Itoa(axis) + ", " + strconv.FormatBool(keep) + ")", goErr: true,
+ py: py + ")",
+ }, nil
+ }
+}
+
+func buildCumSum(c call) (plan, error) {
+ v, err := parseInts(c.args)
+ if err != nil {
+ return plan{}, err
+ }
+ in, name := c.in, c.inName
+ switch len(v) {
+ case 0:
+ return plan{
+ run: func() (*ndarray.Array, error) { return in.CumSumFlat(), nil },
+ goRHS: name + ".CumSumFlat()",
+ py: "np.cumsum(" + name + ")",
+ }, nil
+ case 1:
+ return plan{
+ run: func() (*ndarray.Array, error) { return in.CumSum(v[0]) },
+ goRHS: name + ".CumSum(" + strconv.Itoa(v[0]) + ")", goErr: true,
+ py: "np.cumsum(" + name + ", axis=" + strconv.Itoa(v[0]) + ")",
+ }, nil
+ }
+ return plan{}, errors.New("CumSum takes at most one axis")
+}
+
+// --- binary ------------------------------------------------------------------
+
+// broadcastSize is the element count of two shapes broadcast together, or 0
+// when they do not broadcast (go-ndarray then reports the error itself).
+func broadcastSize(a, b []int) []int {
+ n := max(len(a), len(b))
+ out := make([]int, n)
+ for i := 0; i < n; i++ {
+ da, db := 1, 1
+ if j := len(a) - n + i; j >= 0 {
+ da = a[j]
+ }
+ if j := len(b) - n + i; j >= 0 {
+ db = b[j]
+ }
+ switch {
+ case da == db, db == 1:
+ out[i] = da
+ case da == 1:
+ out[i] = db
+ default:
+ return nil
+ }
+ }
+ return out
+}
+
+// operand resolves the single argument of a binary operation to a step.
+func operand(name string, c call) (*ndarray.Array, string, error) {
+ if len(c.args) != 1 {
+ return nil, "", fmt.Errorf("%s takes one operand", name)
+ }
+ if !isName(c.args[0]) {
+ return nil, "", fmt.Errorf("%s: %q is not a step name", name, c.args[0])
+ }
+ b, err := c.lookup(c.args[0])
+ return b, c.args[0], err
+}
+
+func buildArith(goName, pyOp string) func(c call) (plan, error) {
+ return func(c call) (plan, error) {
+ in, name := c.in, c.inName
+ if len(c.args) == 1 && !isName(c.args[0]) {
+ v, err := parseFloat(c.args[0])
+ if err != nil {
+ return plan{}, fmt.Errorf("%s: %q is neither a step name nor a number", goName, c.args[0])
+ }
+ f := map[string]func(float64) *ndarray.Array{
+ "Add": in.AddScalar, "Sub": in.SubScalar, "Mul": in.MulScalar, "Div": in.DivScalar,
+ }[goName]
+ return plan{
+ run: func() (*ndarray.Array, error) { return f(v), nil },
+ goRHS: name + "." + goName + "Scalar(" + formatFloat(v) + ")",
+ py: name + " " + pyOp + " " + formatFloat(v),
+ }, nil
+ }
+ b, bName, err := operand(goName, c)
+ if err != nil {
+ return plan{}, err
+ }
+ if err := checkSize(broadcastSize(in.Shape(), b.Shape())); err != nil {
+ return plan{}, err
+ }
+ f := map[string]func(*ndarray.Array) (*ndarray.Array, error){
+ "Add": in.Add, "Sub": in.Sub, "Mul": in.Mul, "Div": in.Div,
+ }[goName]
+ return plan{
+ run: func() (*ndarray.Array, error) { return f(b) },
+ goRHS: name + "." + goName + "(" + bName + ")", goErr: true,
+ py: name + " " + pyOp + " " + bName,
+ }, nil
+ }
+}
+
+func buildArrayBinary(goName, pyFmt string) func(c call) (plan, error) {
+ return func(c call) (plan, error) {
+ b, bName, err := operand(goName, c)
+ if err != nil {
+ return plan{}, err
+ }
+ in, name := c.in, c.inName
+ as, bs := in.Shape(), b.Shape()
+ switch goName {
+ case "MatMul", "Dot":
+ // The result is at most the two outer extents (times any batch);
+ // bound it by the product of every non-contracted extent.
+ outer := append(append([]int(nil), as...), bs...)
+ if len(as) > 0 && len(bs) > 0 {
+ outer = append(append([]int(nil), as[:len(as)-1]...), bs[len(bs)-1])
+ }
+ err = checkSize(outer)
+ default:
+ err = checkSize(broadcastSize(as, bs))
+ }
+ if err != nil {
+ return plan{}, err
+ }
+ f := map[string]func(*ndarray.Array) (*ndarray.Array, error){
+ "Maximum": in.Maximum, "Minimum": in.Minimum, "MatMul": in.MatMul, "Dot": in.Dot,
+ }[goName]
+ return plan{
+ run: func() (*ndarray.Array, error) { return f(b) },
+ goRHS: name + "." + goName + "(" + bName + ")", goErr: true,
+ py: fmt.Sprintf(pyFmt, name, bName),
+ }, nil
+ }
+}
+
+func buildOuter(c call) (plan, error) {
+ b, bName, err := operand("Outer", c)
+ if err != nil {
+ return plan{}, err
+ }
+ in, name := c.in, c.inName
+ if err := checkSize([]int{in.Size(), b.Size()}); err != nil {
+ return plan{}, err
+ }
+ return plan{
+ run: func() (*ndarray.Array, error) { return in.Outer(b), nil },
+ goRHS: name + ".Outer(" + bName + ")",
+ py: "np.outer(" + name + ", " + bName + ")",
+ }, nil
+}
+
+// --- joining -----------------------------------------------------------------
+
+func buildJoin(goName, pyName string) func(c call) (plan, error) {
+ return func(c call) (plan, error) {
+ if len(c.args) < 2 {
+ return plan{}, fmt.Errorf("%s takes the other steps, then an axis", goName)
+ }
+ axis, err := parseInt(c.args[len(c.args)-1])
+ if err != nil {
+ return plan{}, fmt.Errorf("%s: the last argument is the axis: %v", goName, err)
+ }
+ arrays := []*ndarray.Array{c.in}
+ names := []string{c.inName}
+ total := c.in.Size()
+ for _, n := range c.args[:len(c.args)-1] {
+ if !isName(n) {
+ return plan{}, fmt.Errorf("%s: %q is not a step name", goName, n)
+ }
+ a, err := c.lookup(n)
+ if err != nil {
+ return plan{}, err
+ }
+ arrays = append(arrays, a)
+ names = append(names, n)
+ total += a.Size()
+ }
+ if err := checkSize([]int{total}); err != nil {
+ return plan{}, err
+ }
+ f := ndarray.Concatenate
+ if goName == "Stack" {
+ f = ndarray.Stack
+ }
+ list := strings.Join(names, ", ")
+ return plan{
+ run: func() (*ndarray.Array, error) { return f(arrays, axis) },
+ goRHS: "ndarray." + goName + "([]*ndarray.Array{" + list + "}, " + strconv.Itoa(axis) + ")", goErr: true,
+ py: "np." + pyName + "([" + list + "], axis=" + strconv.Itoa(axis) + ")",
+ }, nil
+ }
+}
diff --git a/playground/engine/presets.go b/playground/engine/presets.go
new file mode 100644
index 0000000..ad26cf3
--- /dev/null
+++ b/playground/engine/presets.go
@@ -0,0 +1,95 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package engine
+
+// Call is one step of a preset: an operation, its input and its arguments.
+type Call struct{ Op, Input, Args string }
+
+// Preset is a ready-made pipeline that shows one idea.
+type Preset struct {
+ Name string
+ Calls []Call
+ // Next, when set, is filled into the form after loading, not applied:
+ // the visitor presses Apply and sees what happens.
+ Next *Call
+}
+
+// Presets are the examples offered in the menu. The first is what the page
+// opens on; it is the README's example.
+var Presets = []Preset{
+ {Name: "The README example", Calls: []Call{
+ {"Arange", "", "0, 6, 1"},
+ {"Reshape", "a", "2, -1"},
+ {"FromData", "", "[10, 20, 30]"},
+ {"Transpose", "b", ""},
+ {"Sum", "b", "0"},
+ {"Slice", "b", ":, 0"},
+ {"Add", "b", "c"},
+ }},
+ {Name: "Views share data", Calls: []Call{
+ {"Arange", "", "0, 24, 1"},
+ {"Reshape", "a", "4, 6"},
+ {"Transpose", "b", ""},
+ {"Slice", "b", "1:3, ::2"},
+ {"Slice", "b", "::-1, -1"},
+ {"Copy", "d", ""},
+ }},
+ {Name: "Broadcasting", Calls: []Call{
+ {"Arange", "", "0, 4, 1"},
+ {"Reshape", "a", "4, 1"},
+ {"Linspace", "", "0, 1, 5"},
+ {"Mul", "b", "c"},
+ {"Add", "d", "100"},
+ }},
+ {Name: "Reductions along an axis", Calls: []Call{
+ {"Arange", "", "1, 13, 1"},
+ {"Reshape", "a", "3, 4"},
+ {"Sum", "b", "0"},
+ {"Mean", "b", "1, keepdims"},
+ {"Max", "b", ""},
+ {"ArgMax", "b", "1"},
+ {"CumSum", "b", "1"},
+ }},
+ {Name: "Ufuncs on a grid", Calls: []Call{
+ {"Linspace", "", "0, 6.283185307179586, 16"},
+ {"Reshape", "a", "1, 16"},
+ {"Reshape", "a", "16, 1"},
+ {"Sin", "b", ""},
+ {"Cos", "c", ""},
+ {"Mul", "d", "e"},
+ }},
+ {Name: "Concatenate and stack", Calls: []Call{
+ {"Ones", "", "2, 3"},
+ {"Full", "", "5, 2, 3"},
+ {"Concatenate", "a", "b, 0"},
+ {"Concatenate", "a", "b, 1"},
+ {"Stack", "a", "b, 0"},
+ }},
+ {Name: "Linear algebra", Calls: []Call{
+ {"Arange", "", "0, 6, 1"},
+ {"Reshape", "a", "2, 3"},
+ {"Transpose", "b", ""},
+ {"MatMul", "b", "c"},
+ {"Eye", "", "3, 3, 0"},
+ {"Dot", "c", "b"},
+ {"Outer", "a", "a"},
+ }},
+ {Name: "A shape mismatch", Calls: []Call{
+ {"Arange", "", "0, 6, 1"},
+ {"Reshape", "a", "2, 3"},
+ {"Ones", "", "3, 2"},
+ }, Next: &Call{"Add", "b", "c"}},
+}
+
+// Load replaces the session's steps with a preset's. It stops at the first
+// call that fails and returns its error, keeping the steps before it.
+func (s *Session) Load(p Preset) error {
+ s.Reset()
+ for _, c := range p.Calls {
+ if _, err := s.Apply(c.Op, c.Input, c.Args); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/playground/engine/session.go b/playground/engine/session.go
new file mode 100644
index 0000000..3745c63
--- /dev/null
+++ b/playground/engine/session.go
@@ -0,0 +1,207 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Package engine is the model of the go-ndarray playground: a pipeline of
+// steps, each one go-ndarray operation applied to earlier results, with the
+// Go and NumPy spellings of every step and what each result looks like in
+// memory. Everything here is computed by go-ndarray itself; the package has
+// no user interface and no JavaScript.
+package engine
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/go-ndarray/ndarray"
+)
+
+// Step is one applied operation and its result.
+type Step struct {
+ Name string // the variable the result is bound to: a, b, c, …
+ Op string
+ Input string // the step the operation reads; "" for creation
+ Args string
+
+ Result *ndarray.Array // a 0-d array for a reduction to a number
+ Scalar bool // the Go call returns a number, not an *Array
+ Layout Layout
+ Shares []string // earlier steps whose memory the result points into
+
+ Go string // the Go statement
+ GoErr bool // the Go call returns an error too
+ Py string // the NumPy statement
+
+ Elapsed time.Duration // mean wall time of one call
+ Runs int // how many calls the mean is over
+}
+
+// Timing budget: a call is repeated until the calls together take
+// timingBudget (at most timingMaxRuns calls), because one call of a small
+// operation is below the browser clock's resolution.
+const (
+ timingBudget = 2 * time.Millisecond
+ timingMaxRuns = 1000
+)
+
+// Session is a pipeline of steps.
+type Session struct {
+ Steps []Step
+ // Clock is the time source for timing; nil means time.Now.
+ Clock func() time.Time
+}
+
+func (s *Session) now() time.Time {
+ if s.Clock != nil {
+ return s.Clock()
+ }
+ return time.Now()
+}
+
+// lookup finds a step's array by name, refusing a step whose Go value is a
+// number rather than an array.
+func (s *Session) lookup(name string) (*ndarray.Array, error) {
+ for _, st := range s.Steps {
+ if st.Name == name {
+ if st.Scalar {
+ return nil, fmt.Errorf("%s is a number in Go, not an *ndarray.Array; reduce along an axis to keep an array", name)
+ }
+ return st.Result, nil
+ }
+ }
+ return nil, fmt.Errorf("there is no step named %q", name)
+}
+
+// Names lists the steps whose result is an array, the possible inputs.
+func (s *Session) Names() []string {
+ var out []string
+ for _, st := range s.Steps {
+ if !st.Scalar {
+ out = append(out, st.Name)
+ }
+ }
+ return out
+}
+
+// Apply runs one operation and appends it as a new step. input names the
+// step the operation reads (ignored for creation). An error — a bad
+// argument line, or go-ndarray's own error — appends nothing.
+func (s *Session) Apply(opName, input, args string) (Step, error) {
+ op, ok := OpByName(opName)
+ if !ok {
+ return Step{}, fmt.Errorf("unknown operation %q", opName)
+ }
+ c := call{args: splitArgs(args), lookup: s.lookup}
+ if op.Kind != KindCreate {
+ if input == "" {
+ return Step{}, fmt.Errorf("%s needs an input: create an array first", op.Name)
+ }
+ in, err := s.lookup(input)
+ if err != nil {
+ return Step{}, err
+ }
+ c.in, c.inName = in, input
+ }
+ p, err := op.build(c)
+ if err != nil {
+ return Step{}, err
+ }
+ res, elapsed, runs, err := s.timed(p.run)
+ if err != nil {
+ return Step{}, err
+ }
+ st := Step{
+ Name: StepName(len(s.Steps)), Op: op.Name, Args: strings.TrimSpace(args),
+ Result: res, Scalar: p.scalar, Layout: LayoutOf(res),
+ GoErr: p.goErr, Elapsed: elapsed, Runs: runs,
+ }
+ if op.Kind != KindCreate {
+ st.Input = input
+ }
+ if p.goErr {
+ st.Go = st.Name + ", err := " + p.goRHS
+ } else {
+ st.Go = st.Name + " := " + p.goRHS
+ }
+ st.Py = st.Name + " = " + p.py
+ for _, prev := range s.Steps {
+ if !prev.Scalar && SharesMemory(st.Layout, prev.Layout) {
+ st.Shares = append(st.Shares, prev.Name)
+ }
+ }
+ s.Steps = append(s.Steps, st)
+ return st, nil
+}
+
+// timed runs f once for its result, then again until the calls add up to
+// timingBudget, and returns the mean. A panic in go-ndarray (Outer panics on a
+// result too big to represent) is reported as an error, not a dead tab.
+func (s *Session) timed(f func() (*ndarray.Array, error)) (res *ndarray.Array, mean time.Duration, runs int, err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ res, mean, runs, err = nil, 0, 0, fmt.Errorf("go-ndarray panicked: %v", r)
+ }
+ }()
+ start := s.now()
+ res, err = f()
+ if err != nil {
+ return nil, 0, 0, err
+ }
+ runs = 1
+ total := s.now().Sub(start)
+ for total < timingBudget && runs < timingMaxRuns {
+ t0 := s.now()
+ _, _ = f()
+ total += s.now().Sub(t0)
+ runs++
+ }
+ return res, total / time.Duration(runs), runs, nil
+}
+
+// RemoveLast drops the last step.
+func (s *Session) RemoveLast() {
+ if len(s.Steps) > 0 {
+ s.Steps = s.Steps[:len(s.Steps)-1]
+ }
+}
+
+// Reset drops every step.
+func (s *Session) Reset() { s.Steps = nil }
+
+// GoProgram is the whole pipeline as a Go program that compiles and prints
+// every step, so it can be pasted into a file and run.
+func (s *Session) GoProgram() string {
+ if len(s.Steps) == 0 {
+ return "// Create an array to start."
+ }
+ var b strings.Builder
+ b.WriteString("package main\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/go-ndarray/ndarray\"\n)\n\nfunc main() {\n")
+ for _, st := range s.Steps {
+ b.WriteString("\t" + st.Go + "\n")
+ if st.GoErr {
+ b.WriteString("\tcheck(err)\n")
+ }
+ }
+ for _, st := range s.Steps {
+ fmt.Fprintf(&b, "\tfmt.Println(%q, %s)\n", st.Name+" =", st.Name)
+ }
+ b.WriteString("}\n\nfunc check(err error) {\n\tif err != nil {\n\t\tpanic(err)\n\t}\n}\n")
+ return b.String()
+}
+
+// PyProgram is the same pipeline in NumPy.
+func (s *Session) PyProgram() string {
+ if len(s.Steps) == 0 {
+ return "# Create an array to start."
+ }
+ var b strings.Builder
+ b.WriteString("import numpy as np\n\n")
+ for _, st := range s.Steps {
+ b.WriteString(st.Py + "\n")
+ }
+ b.WriteString("\n")
+ for _, st := range s.Steps {
+ fmt.Fprintf(&b, "print(%q, %s)\n", st.Name+" =", st.Name)
+ }
+ return b.String()
+}
diff --git a/playground/events.go b/playground/events.go
new file mode 100644
index 0000000..731f550
--- /dev/null
+++ b/playground/events.go
@@ -0,0 +1,103 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package playground
+
+import "github.com/go-widgets/toolkit"
+
+// Every pointer event enters through the root box, in the root's own
+// coordinates: the boxes translate it for each child and move keyboard focus
+// on a click (toolkit's focus system), so the View does no hit-testing of its
+// own. Only the open popovers come first, because they float above the boxes.
+
+// rootEvent re-bases a surface-space event onto the root box.
+func (s *State) rootEvent(kind toolkit.EventKind, x, y int) toolkit.Event {
+ r := s.root.Bounds()
+ return toolkit.Event{Kind: kind, X: x - r.X, Y: y - r.Y}
+}
+
+// HandleClick routes a primary-button press at surface (x, y).
+func (s *State) HandleClick(x, y int) bool {
+ for _, d := range s.dropdowns {
+ if d.PopoverClick(x, y) {
+ s.dirty = true
+ return true
+ }
+ }
+ s.dragTarget = s.hit(x, y)
+ s.root.OnEvent(s.rootEvent(toolkit.EventClick, x, y))
+ s.dirty = true
+ return true
+}
+
+// hit finds the leaf widget under (x, y), for the pointer capture.
+func (s *State) hit(x, y int) toolkit.Widget {
+ for _, w := range []toolkit.Widget{s.steps, s.table, s.code} {
+ if w.Bounds().Contains(x, y) {
+ return w
+ }
+ }
+ return nil
+}
+
+// HandleMove routes a pointer move: a drag of the captured widget (a
+// scrollbar thumb) while the button is held, else a hover.
+func (s *State) HandleMove(x, y int) bool {
+ if s.dragTarget != nil {
+ r := s.dragTarget.Bounds()
+ s.dragTarget.OnEvent(toolkit.Event{Kind: toolkit.EventMouseDrag, X: x - r.X, Y: y - r.Y})
+ s.dirty = true
+ return true
+ }
+ s.root.OnEvent(s.rootEvent(toolkit.EventMouseMove, x, y))
+ return s.dirty
+}
+
+// HandleRelease ends a drag.
+func (s *State) HandleRelease(x, y int) bool {
+ if s.dragTarget == nil {
+ return false
+ }
+ r := s.dragTarget.Bounds()
+ s.dragTarget.OnEvent(toolkit.Event{Kind: toolkit.EventMouseUp, X: x - r.X, Y: y - r.Y})
+ s.dragTarget = nil
+ s.dirty = true
+ return true
+}
+
+// HandleScroll routes a wheel of dy rows (dx sideways) at (x, y): to an open
+// popover's list first, else to the widget under the pointer.
+func (s *State) HandleScroll(x, y, dx, dy int) bool {
+ for _, d := range s.dropdowns {
+ if d.PopoverOpen() && d.PopoverBounds().Contains(x, y) {
+ d.OnEvent(toolkit.Event{Kind: toolkit.EventScroll, Delta: dy})
+ s.dirty = true
+ return true
+ }
+ }
+ e := s.rootEvent(toolkit.EventScroll, x, y)
+ e.Delta, e.DeltaX = dy, dx
+ s.root.OnEvent(e)
+ s.dirty = true
+ return true
+}
+
+// HandleChar types a printable character into the focused widget.
+func (s *State) HandleChar(ch string) bool {
+ s.root.OnEvent(toolkit.Event{Kind: toolkit.EventChar, Code: ch})
+ s.dirty = true
+ return true
+}
+
+// HandleKeyDown delivers a named key (Enter, Backspace, arrows, Tab, Escape)
+// to the focused widget; Escape also closes any open popover.
+func (s *State) HandleKeyDown(code string) bool {
+ if code == "Escape" {
+ for _, d := range s.dropdowns {
+ d.Open().Set(false)
+ }
+ }
+ s.root.OnEvent(toolkit.Event{Kind: toolkit.EventKeyDown, Code: code})
+ s.dirty = true
+ return true
+}
diff --git a/playground/go.mod b/playground/go.mod
new file mode 100644
index 0000000..655a008
--- /dev/null
+++ b/playground/go.mod
@@ -0,0 +1,41 @@
+module github.com/go-ndarray/go-ndarray.github.io/playground
+
+go 1.27.1
+
+require (
+ github.com/go-ndarray/ndarray v0.7.1
+ github.com/go-widgets/mvvm v0.13.0
+ github.com/go-widgets/mvvmtk v0.14.1
+ github.com/go-widgets/painter v0.15.0
+ github.com/go-widgets/toolkit v0.328.0
+)
+
+require (
+ github.com/ajroetker/go-highway v0.0.4 // indirect
+ github.com/andybalholm/brotli v1.2.5 // indirect
+ github.com/coder/websocket v1.8.15 // indirect
+ github.com/go-crdt/collab v0.74.0 // indirect
+ github.com/go-crdt/crdt v0.55.0 // indirect
+ github.com/go-gfx/gfx v0.34.0 // indirect
+ github.com/go-icons/iconoir v0.2.0 // indirect
+ github.com/go-images/gif v0.1.0 // indirect
+ github.com/go-images/images v0.0.0-20260927173152-87444e36aac4 // indirect
+ github.com/go-images/jpeg v0.2.0 // indirect
+ github.com/go-images/jpeg2000 v0.1.0 // indirect
+ github.com/go-images/png v0.1.0 // indirect
+ github.com/go-opentype/fonts v0.10.0 // indirect
+ github.com/go-opentype/opentype v0.13.0 // indirect
+ github.com/go-opentype/shape v0.5.0 // indirect
+ github.com/go-richdoc/richdoc v0.4.0 // indirect
+ github.com/go-typeset/bidi v0.3.0 // indirect
+ github.com/sergeymakinen/go-bmp v1.0.0 // indirect
+ github.com/sergeymakinen/go-ico v1.0.0 // indirect
+ github.com/tannevaled/gobig2 v0.2.0 // indirect
+ golang.org/x/image v0.46.0 // indirect
+ golang.org/x/net v0.58.0 // indirect
+ golang.org/x/sys v0.48.0 // indirect
+ golang.org/x/text v0.42.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
+ google.golang.org/grpc v1.84.0 // indirect
+ google.golang.org/protobuf v1.36.12 // indirect
+)
diff --git a/playground/go.sum b/playground/go.sum
new file mode 100644
index 0000000..cfb7a4d
--- /dev/null
+++ b/playground/go.sum
@@ -0,0 +1,74 @@
+github.com/ajroetker/go-highway v0.0.4 h1:RDQo+9OhTXI6BFctLo+5gYpHNbb92VYJ0ObnR4l6xAQ=
+github.com/ajroetker/go-highway v0.0.4/go.mod h1:C/zYPNSSpOaraejY89FUTZTyQNEhi5+rEbU0LjlqJeU=
+github.com/andybalholm/brotli v1.2.5 h1:BSI8V4zmx/3BAn6OKjF1PmfVq7Aoi52AdFsi6bpCx+s=
+github.com/andybalholm/brotli v1.2.5/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
+github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/go-crdt/collab v0.74.0 h1:Q+fv5M57UOGzgse9khZEYNCDPxY2kH4CHhEXqSV/6qc=
+github.com/go-crdt/collab v0.74.0/go.mod h1:o1aOUE4PI5EfvNPzetDO/cgoGStcuCQueoPaFTf4LiU=
+github.com/go-crdt/crdt v0.55.0 h1:D/LHQ4OgSJRomMvEeYzRwwnP0hnXGngWevXglmpXIxI=
+github.com/go-crdt/crdt v0.55.0/go.mod h1:vbfvns9B9FUaMi2sPKzMYttKmbxfgO7EK3Yu82SrlYw=
+github.com/go-gfx/gfx v0.34.0 h1:K46srusNnZls/xkmJRvxpBajlptgrXc6d3exa4ZxhoA=
+github.com/go-gfx/gfx v0.34.0/go.mod h1:TC2X+3GMXm8mYZPmp/v7bFrRa69p+k0xz0Nabbd9sII=
+github.com/go-icons/iconoir v0.2.0 h1:I5AnDEIPgYfawTmwzemDRJjlYCUbFHuS8GJXrjADhXc=
+github.com/go-icons/iconoir v0.2.0/go.mod h1:Td/M2vy4xdVv4aOudAskV0nXR9Wv0MlhWvyRIzPBz5U=
+github.com/go-images/gif v0.1.0 h1:g2ZnRd41PutXIt1mCZSZsRpqqUiD0m+UbAjZ5ylC0RU=
+github.com/go-images/gif v0.1.0/go.mod h1:8mk9sA/K3U61jL+rj4U+Yv3TRKeoGp2fMxF25ykOOwY=
+github.com/go-images/images v0.0.0-20260927173152-87444e36aac4 h1:Likv0wVxr9vDo2ITd4A7SOMKCrN3efOHs9CsANj2y+0=
+github.com/go-images/images v0.0.0-20260927173152-87444e36aac4/go.mod h1:bc99LrkZxVUjP+Di3JCmf4SoHzim/PNcHEJfKd1yUtg=
+github.com/go-images/jpeg v0.2.0 h1:dn55cTczPdOJf+NQq+sETCFeov/KeP1haF1ES+FJa+4=
+github.com/go-images/jpeg v0.2.0/go.mod h1:2C1DevAM+HaQlysTLbSdyol8X/RlvmsXmHAXs9teAqo=
+github.com/go-images/jpeg2000 v0.1.0 h1:SiFAZ6NGvGLU+VmxKji74RiSQDeK6TyjUZr1wjlxRzY=
+github.com/go-images/jpeg2000 v0.1.0/go.mod h1:MwSmrbiP6RK7eLphoNyzY60BAXWOD+8LVdBYDPIBrN4=
+github.com/go-images/png v0.1.0 h1:OI4yHotS0lA75sLnG2FTL2Vooh4PWpVWEidre1msSe0=
+github.com/go-images/png v0.1.0/go.mod h1:V9vaEbr2nrZGorUWn4xv9F24Yuu1/qLVtmailM9GBVo=
+github.com/go-ndarray/ndarray v0.7.1 h1:UCDBiqf/vw/5Q6IVT4JUQhnLagdritPf6ZV4TU4WUbc=
+github.com/go-ndarray/ndarray v0.7.1/go.mod h1:MI/3teJK5hMJ4Chz6Auy2w+DnQAjtbekhHRg+NUbNMI=
+github.com/go-opentype/fonts v0.10.0 h1:9f7ik795SlBEJjz6iqBE/ZV2PaTauAj537MMWw9t4OQ=
+github.com/go-opentype/fonts v0.10.0/go.mod h1:b+o8v1zDZ6B3GixA398jGILAh09+WwfyoYz52zKe7DA=
+github.com/go-opentype/opentype v0.13.0 h1:q9K7IGDfwMP/ik51ZkwiYf6jbaqPfocvAIuojetd0UQ=
+github.com/go-opentype/opentype v0.13.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU=
+github.com/go-opentype/shape v0.5.0 h1:jHNaOMHNBdDj5EixOevlrrsi92svMxvVMKN7GYaPfxo=
+github.com/go-opentype/shape v0.5.0/go.mod h1:3ImRYNIj6zpwWQ/DV3BWhgMFfmzDITH6XpZw2auQHTo=
+github.com/go-richdoc/richdoc v0.4.0 h1:oLhMdRXRRZr3RW6Zqra3N6k5nOX6GFmvgyDpMKEBywM=
+github.com/go-richdoc/richdoc v0.4.0/go.mod h1:aCX8ulqg5CoKqSWgWeEWul1Oj0d2VxQeaQNI0+jsm7c=
+github.com/go-typeset/bidi v0.3.0 h1:4fjGjejvjE2LzLNzY4si8PkVO321NcsKIiANhWT3jF4=
+github.com/go-typeset/bidi v0.3.0/go.mod h1:ct3cmYT8Qt1FGJQ+2QaakrUxqaP4ZysgdQQg2ym5+Xo=
+github.com/go-widgets/mvvm v0.13.0 h1:cfBIsT29StK0gknb4jqiOQhKZNcWf+PUR/jYpuoHmm0=
+github.com/go-widgets/mvvm v0.13.0/go.mod h1:9DjoZRoUkdBcMd0KeiN6T6zWCjOR1enX1Rmcj96WDFY=
+github.com/go-widgets/mvvmtk v0.14.1 h1:JMuOXLylXDd0uU5ca9NOHUhStgPy5DNtSxLrYzVG8DE=
+github.com/go-widgets/mvvmtk v0.14.1/go.mod h1:fEJTlTKR+6WH3BFQ2D+0J8ORZ1vPhoHAKPVX0nzZ2sI=
+github.com/go-widgets/painter v0.15.0 h1:0oeaRDTPcIe5/b0wHtYbAUsnReoidkSapr8y+iZJwDk=
+github.com/go-widgets/painter v0.15.0/go.mod h1:FSLNdrtzfDF3md+vtaORyS4nrOKLW6eLaCT5BuO9EWg=
+github.com/go-widgets/toolkit v0.328.0 h1:S653IRgxnyPjR/+2mickkoGjDZq3TzG+ym+C3Li8Gpo=
+github.com/go-widgets/toolkit v0.328.0/go.mod h1:IfzBIAPNK6m9FGsEGgaEefyx8DZ1441HYP1WK8n74pM=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/grpc-transports/websocket v0.2.0 h1:n4lN066PPcVTvTfLdwa84gfNiAzvS9k4SSQ1yWuG3+g=
+github.com/grpc-transports/websocket v0.2.0/go.mod h1:2z7Qy11qgPaGDfqzImOt0iyLwH0wjfsvaiidCIIpzgg=
+github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=
+github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
+github.com/sergeymakinen/go-ico v1.0.0 h1:uL3khgvKkY6WfAetA+RqsguClBuu7HpvBB/nq/Jvr80=
+github.com/sergeymakinen/go-ico v1.0.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
+github.com/tannevaled/gobig2 v0.2.0 h1:yx97/ltljALrIWDFVqHjPUIjAp3bfUUhH8Z8UROvNLc=
+github.com/tannevaled/gobig2 v0.2.0/go.mod h1:X0S1H+N35kg6zYgVYRqGMsGj3C35zs+iA2r1MXyPcV4=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
+golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
+google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
+google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
+google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
diff --git a/playground/hack/lint-negative-controls.sh b/playground/hack/lint-negative-controls.sh
new file mode 100755
index 0000000..8c79200
--- /dev/null
+++ b/playground/hack/lint-negative-controls.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# Copyright (c) the go-ndarray authors.
+# SPDX-License-Identifier: BSD-3-Clause
+#
+# Negative controls for the two UI guards. A green `go vet -vettool=…` only
+# proves a guard is SILENT on this tree; this script proves each one BITES on
+# this app's own code:
+#
+# bricolint a raw painter draw call spliced into State.Draw -> must fail
+# mvvmlint a direct write of a widget's state field in app.go -> must fail
+#
+# and that both are green again once the injection is removed.
+#
+# Usage: BRICOLINT=/path/bricolint MVVMLINT=/path/mvvmlint bash hack/lint-negative-controls.sh
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+target="$root/app.go"
+backup="$(mktemp)"
+cp "$target" "$backup"
+restore() { [ -s "$backup" ] && cp "$backup" "$target"; rm -f "$backup"; }
+trap restore EXIT
+
+vet() { ( cd "$root" && GOWORK=off go vet -vettool="$1" ./... ) >/dev/null 2>&1 && echo 0 || echo 1; }
+
+# inject : put line right after the anchor line in app.go.
+inject() {
+ grep -qF "$1" "$backup" || { echo "anchor not found: $1" >&2; exit 2; }
+ awk -v a="$1" -v l="$2" '{ print } index($0, a) { print l }' "$backup" > "$target"
+}
+
+for guard in bricolint mvvmlint; do
+ case "$guard" in
+ bricolint) tool="${BRICOLINT:?set BRICOLINT}"
+ anchor='p := painter.NewPixelPainter(buf, s.w, s.h)'
+ line=' p.FillRect(painter.Rect{}, painter.RGBA{}) // negative control' ;;
+ mvvmlint) tool="${MVVMLINT:?set MVVMLINT}"
+ anchor='s.steps = toolkit.NewListBox(nil)'
+ line=' s.steps.Items = nil // negative control' ;;
+ esac
+ cp "$backup" "$target"
+ [ "$(vet "$tool")" = 0 ] || { echo "FAIL: $guard is red on the clean tree"; exit 1; }
+ inject "$anchor" "$line"
+ [ "$(vet "$tool")" = 1 ] || { echo "FAIL: $guard stayed green with an injected violation"; exit 1; }
+ cp "$backup" "$target"
+ [ "$(vet "$tool")" = 0 ] || { echo "FAIL: $guard did not return to green"; exit 1; }
+ echo "ok: $guard bites on an injected violation and is silent otherwise"
+done
diff --git a/playground/view_binding.go b/playground/view_binding.go
new file mode 100644
index 0000000..dc24101
--- /dev/null
+++ b/playground/view_binding.go
@@ -0,0 +1,86 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+// The bindings mvvmtk does not provide. This is the one file allowed to set a
+// widget's data fields (mvvmlint exempts *_binding.go): each function here is a
+// one-way or two-way link between a ViewModel Observable and a widget, used
+// once from NewState — never a per-frame copy.
+
+package playground
+
+import (
+ "math"
+
+ "github.com/go-ndarray/go-ndarray.github.io/playground/engine"
+ "github.com/go-widgets/mvvm"
+ "github.com/go-widgets/toolkit"
+)
+
+// bindEntry links an Entry and a string Observable both ways. A value from the
+// ViewModel goes through SetText, which parks the caret at the end — the place
+// a visitor expects it after the form offers new default arguments.
+func bindEntry(e *toolkit.Entry, obs *mvvm.Observable[string], invalidate func()) (unbind func()) {
+ e.SetText(obs.Get())
+ a := obs.Subscribe(func(s string) {
+ if e.Text().Get() != s {
+ e.SetText(s)
+ }
+ invalidate()
+ })
+ b := e.Text().Subscribe(func(s string) { obs.Set(s) })
+ return func() { a(); b() }
+}
+
+// bindGrid shows the selected array as a heatmap table: a row-label column,
+// then one column per index of the last axis, each value cell filled on the
+// viridis scale between the smallest and the largest value shown.
+func bindGrid(t *toolkit.Table, obs *mvvm.Observable[*engine.Grid], invalidate func()) (unbind func()) {
+ show := func(g *engine.Grid) {
+ t.Selected().Set(-1)
+ t.ScrollRow().Set(0)
+ t.ScrollX().Set(0)
+ if g == nil || len(g.Values) == 0 {
+ t.Columns, t.Rows, t.CellFill = nil, nil, nil
+ invalidate()
+ return
+ }
+ cols := []toolkit.TableColumn{{Title: "", Width: toolkit.Scaled(rowLabelW), Align: toolkit.AlignRight}}
+ for _, c := range g.ColLabels {
+ cols = append(cols, toolkit.TableColumn{Title: c, Width: toolkit.Scaled(cellW), Align: toolkit.AlignRight})
+ }
+ rows := make([][]string, len(g.Values))
+ for i, vals := range g.Values {
+ row := make([]string, 0, len(vals)+1)
+ row = append(row, g.RowLabels[i])
+ for _, v := range vals {
+ row = append(row, engine.FormatValue(v))
+ }
+ rows[i] = row
+ }
+ t.Columns, t.Rows = cols, rows
+ t.CellFill = heatFill(g)
+ invalidate()
+ }
+ show(obs.Get())
+ return obs.Subscribe(show)
+}
+
+// heatFill colours cell (row, col) of a grid table: column 0 holds the row
+// labels and is not filled, nor is a value that is not finite.
+func heatFill(g *engine.Grid) func(row, col int) (toolkit.RGBA, bool) {
+ lo, span := g.Min, g.Max-g.Min
+ return func(row, col int) (toolkit.RGBA, bool) {
+ if col == 0 || row >= len(g.Values) || col-1 >= len(g.Values[row]) {
+ return toolkit.RGBA{}, false
+ }
+ v := g.Values[row][col-1]
+ if math.IsNaN(v) || math.IsInf(v, 0) {
+ return toolkit.RGBA{}, false
+ }
+ f := 0.5
+ if span > 0 {
+ f = (v - lo) / span
+ }
+ return toolkit.Viridis(f), true
+ }
+}
diff --git a/playground/vm/vm.go b/playground/vm/vm.go
new file mode 100644
index 0000000..f424728
--- /dev/null
+++ b/playground/vm/vm.go
@@ -0,0 +1,416 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Package vm is the ViewModel of the go-ndarray playground. It holds every
+// piece of state the page shows or edits as a go-widgets/mvvm Observable,
+// ObservableList or Command, and references no widget: the View binds to
+// these, so the whole behaviour is unit-tested here without a canvas.
+package vm
+
+import (
+ "fmt"
+ "runtime"
+ "runtime/debug"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-ndarray/go-ndarray.github.io/playground/engine"
+ "github.com/go-widgets/mvvm"
+)
+
+// Code languages of the code panel.
+const (
+ LangGo = iota
+ LangNumPy
+)
+
+// ViewModel is the whole playground state.
+type ViewModel struct {
+ session engine.Session
+
+ // OpNames and PresetNames are the fixed menus.
+ OpNames []string
+ PresetNames []string
+
+ // --- the form that builds the next step ---------------------------------
+ OpIndex *mvvm.Observable[int]
+ Inputs *mvvm.ObservableList[string] // steps whose result is an array
+ InputIndex *mvvm.Observable[int]
+ Args *mvvm.Observable[string]
+ Hint *mvvm.Observable[string]
+ PresetIndex *mvvm.Observable[int]
+
+ // --- the pipeline --------------------------------------------------------
+ Steps *mvvm.ObservableList[string] // one line per step
+ Selected *mvvm.Observable[int]
+
+ // --- the selected step ---------------------------------------------------
+ Title *mvvm.Observable[string]
+ Shape *mvvm.Observable[string]
+ Memory *mvvm.Observable[string]
+ Timing *mvvm.Observable[string]
+ GoLine *mvvm.Observable[string]
+ PyLine *mvvm.Observable[string]
+ Grid *mvvm.Observable[*engine.Grid] // nil when there is no step
+ Note *mvvm.Observable[string]
+
+ // --- code panel ----------------------------------------------------------
+ CodeLang *mvvm.Observable[int]
+ Code *mvvm.Observable[string]
+
+ // --- messages ------------------------------------------------------------
+ Error *mvvm.Observable[string]
+ Status *mvvm.Observable[string]
+
+ // --- commands ------------------------------------------------------------
+ Apply *mvvm.Command
+ RemoveLast *mvvm.Command
+ Reset *mvvm.Command
+ LoadPreset *mvvm.Command
+ CopyCode *mvvm.Command
+
+ // Clipboard receives the text CopyCode copies; the host installs it.
+ Clipboard func(string)
+ // Runtime describes where the code runs, for the status line; the
+ // host fills it in. It defaults to the Go runtime's own view.
+ Runtime string
+}
+
+// New builds the ViewModel on the first preset.
+func New() *ViewModel {
+ v := &ViewModel{
+ OpIndex: mvvm.NewObservable(0),
+ Inputs: mvvm.NewObservableList[string](),
+ InputIndex: mvvm.NewObservable(0),
+ Args: mvvm.NewObservable(""),
+ Hint: mvvm.NewObservable(""),
+ PresetIndex: mvvm.NewObservable(0),
+ Steps: mvvm.NewObservableList[string](),
+ Selected: mvvm.NewObservable(-1),
+ Title: mvvm.NewObservable(""),
+ Shape: mvvm.NewObservable(""),
+ Memory: mvvm.NewObservable(""),
+ Timing: mvvm.NewObservable(""),
+ GoLine: mvvm.NewObservable(""),
+ PyLine: mvvm.NewObservable(""),
+ Grid: mvvm.NewObservableEq[*engine.Grid](nil, nil),
+ Note: mvvm.NewObservable(""),
+ CodeLang: mvvm.NewObservable(LangGo),
+ Code: mvvm.NewObservable(""),
+ Error: mvvm.NewObservable(""),
+ Status: mvvm.NewObservable(""),
+ Runtime: runtime.GOOS + "/" + runtime.GOARCH + ", GOMAXPROCS " + strconv.Itoa(runtime.GOMAXPROCS(0)),
+ }
+ for _, op := range engine.Ops {
+ v.OpNames = append(v.OpNames, op.Name)
+ }
+ for _, p := range engine.Presets {
+ v.PresetNames = append(v.PresetNames, p.Name)
+ }
+
+ v.Apply = mvvm.NewCommand(v.apply, nil)
+ v.RemoveLast = mvvm.NewCommand(v.removeLast, func() bool { return len(v.session.Steps) > 0 })
+ v.Reset = mvvm.NewCommand(v.reset, func() bool { return len(v.session.Steps) > 0 })
+ v.LoadPreset = mvvm.NewCommand(v.loadPreset, nil)
+ v.CopyCode = mvvm.NewCommand(v.copyCode, func() bool { return len(v.session.Steps) > 0 })
+ mvvm.BindCanExecute(v.RemoveLast, v.Steps)
+ mvvm.BindCanExecute(v.Reset, v.Steps)
+ mvvm.BindCanExecute(v.CopyCode, v.Steps)
+
+ v.OpIndex.SubscribeChanged(v.opChanged)
+ v.Selected.SubscribeChanged(v.showSelected)
+ v.Selected.SubscribeChanged(v.followSelection)
+ v.CodeLang.SubscribeChanged(v.showCode)
+ // Picking a preset loads it: the menu is the action.
+ v.PresetIndex.SubscribeChanged(v.LoadPreset.Execute)
+
+ v.loadPreset()
+ return v
+}
+
+// Session exposes the model, read-only by convention, for the host's tests.
+func (v *ViewModel) Session() *engine.Session { return &v.session }
+
+// op is the operation the form has selected.
+func (v *ViewModel) op() engine.Op {
+ i := v.OpIndex.Get()
+ if i < 0 || i >= len(engine.Ops) {
+ i = 0
+ }
+ return engine.Ops[i]
+}
+
+// opChanged shows the new operation's hint and offers its default arguments.
+func (v *ViewModel) opChanged() {
+ op := v.op()
+ hint := op.Hint
+ switch op.Kind {
+ case engine.KindCreate:
+ hint = "creates a new array — " + hint
+ default:
+ hint = "applies to the input — " + hint
+ }
+ v.Hint.Set(hint)
+ v.Args.Set(op.Default)
+}
+
+// input is the step name the form has selected as input, "" when none.
+func (v *ViewModel) input() string {
+ i := v.InputIndex.Get()
+ if i < 0 || i >= v.Inputs.Len() {
+ return ""
+ }
+ return v.Inputs.At(i)
+}
+
+// apply runs the form as a new step.
+func (v *ViewModel) apply() {
+ _, err := v.session.Apply(v.op().Name, v.input(), v.Args.Get())
+ if err != nil {
+ v.Error.Set(err.Error())
+ return
+ }
+ v.Error.Set("")
+ v.refresh(len(v.session.Steps) - 1)
+}
+
+func (v *ViewModel) removeLast() {
+ v.session.RemoveLast()
+ v.Error.Set("")
+ v.refresh(len(v.session.Steps) - 1)
+}
+
+func (v *ViewModel) reset() {
+ v.session.Reset()
+ v.Error.Set("")
+ v.refresh(-1)
+ v.selectOp("Arange", "")
+}
+
+// loadPreset replaces the pipeline with the selected preset and, when the
+// preset proposes a next step, puts it in the form.
+func (v *ViewModel) loadPreset() {
+ i := v.PresetIndex.Get()
+ if i < 0 || i >= len(engine.Presets) {
+ i = 0
+ }
+ p := engine.Presets[i]
+ err := v.session.Load(p)
+ v.Error.Set("")
+ if err != nil {
+ v.Error.Set(err.Error())
+ }
+ v.refresh(len(v.session.Steps) - 1)
+ if p.Next != nil {
+ v.selectOp(p.Next.Op, p.Next.Input)
+ v.Args.Set(p.Next.Args)
+ } else {
+ v.selectOp("Reshape", "")
+ }
+}
+
+// selectOp points the form at an operation and, when input is a step name,
+// at that input.
+func (v *ViewModel) selectOp(name, input string) {
+ for i, op := range engine.Ops {
+ if op.Name == name {
+ if v.OpIndex.Get() == i {
+ v.opChanged() // re-offer the default arguments
+ }
+ v.OpIndex.Set(i)
+ }
+ }
+ for i := 0; i < v.Inputs.Len(); i++ {
+ if v.Inputs.At(i) == input {
+ v.InputIndex.Set(i)
+ }
+ }
+}
+
+// refresh republishes the pipeline after it changed and selects step sel.
+func (v *ViewModel) refresh(sel int) {
+ v.Inputs.Clear()
+ v.Inputs.Append(v.session.Names()...)
+ // The newest array is the natural input of the next step.
+ v.InputIndex.Set(v.Inputs.Len() - 1)
+
+ rows := make([]string, len(v.session.Steps))
+ for i, st := range v.session.Steps {
+ rows[i] = stepRow(st)
+ }
+ v.Steps.Clear()
+ v.Steps.Append(rows...)
+
+ if v.Selected.Get() == sel {
+ v.showSelected()
+ }
+ v.Selected.Set(sel)
+ v.showCode()
+ v.status()
+}
+
+// stepRow is a step's line in the pipeline list.
+func stepRow(st engine.Step) string {
+ call := st.Op
+ if st.Input != "" {
+ call = st.Input + "." + st.Op
+ }
+ return st.Name + " = " + call + "(" + st.Args + ") " + engine.ShapeText(st.Layout.Shape)
+}
+
+// followSelection makes the selected step the input of the next one, when
+// its result is an array: inspecting a step is the usual prelude to applying
+// something to it.
+func (v *ViewModel) followSelection() {
+ i := v.Selected.Get()
+ if i < 0 || i >= len(v.session.Steps) {
+ return
+ }
+ name := v.session.Steps[i].Name
+ for j := 0; j < v.Inputs.Len(); j++ {
+ if v.Inputs.At(j) == name {
+ v.InputIndex.Set(j)
+ }
+ }
+}
+
+// showSelected publishes the selected step's details, or blanks them.
+func (v *ViewModel) showSelected() {
+ i := v.Selected.Get()
+ if i < 0 || i >= len(v.session.Steps) {
+ v.Title.Set("No array yet")
+ v.Shape.Set("Pick an operation on the left and press Apply.")
+ v.Memory.Set("")
+ v.Timing.Set("")
+ v.GoLine.Set("")
+ v.PyLine.Set("")
+ v.Grid.Set(nil)
+ v.Note.Set("")
+ return
+ }
+ st := v.session.Steps[i]
+ v.Title.Set(title(st))
+ v.Shape.Set(shapeLine(st))
+ v.Memory.Set(memoryLine(st))
+ v.Timing.Set(timingLine(st))
+ v.GoLine.Set("Go " + st.Go)
+ v.PyLine.Set("NumPy " + st.Py)
+ g := engine.GridOf(st.Result)
+ v.Grid.Set(&g)
+ v.Note.Set(g.Note)
+}
+
+func title(st engine.Step) string {
+ if st.Input == "" {
+ return st.Name + " — " + st.Op + "(" + st.Args + ")"
+ }
+ return st.Name + " — " + st.Op + " of " + st.Input
+}
+
+func shapeLine(st engine.Step) string {
+ shape := st.Layout.Shape
+ s := "shape " + engine.ShapeText(shape) + " · " + strconv.Itoa(st.Result.Size()) + " elements · float64"
+ if st.Scalar {
+ goType := "float64"
+ if st.Op == "ArgMax" {
+ goType = "int"
+ }
+ s = "a number: Go returns a plain " + goType + ", shown here as a 0-d array"
+ }
+ return s
+}
+
+func memoryLine(st engine.Step) string {
+ l := st.Layout
+ if st.Scalar {
+ return "not an array in Go, so it has no strides"
+ }
+ var b strings.Builder
+ b.WriteString("strides " + intsText(l.Strides) + " elements = " + intsText(l.ByteStrides()) + " bytes")
+ if l.Offset != 0 {
+ b.WriteString(" · offset " + strconv.Itoa(l.Offset))
+ }
+ if l.Contiguous() {
+ b.WriteString(" · C-contiguous")
+ } else {
+ b.WriteString(" · not contiguous")
+ }
+ if len(st.Shares) > 0 {
+ b.WriteString(" · VIEW: shares memory with " + strings.Join(st.Shares, ", "))
+ } else {
+ b.WriteString(" · owns its data")
+ }
+ return b.String()
+}
+
+// intsText writes strides as NumPy prints them.
+func intsText(v []int) string { return engine.ShapeText(v) }
+
+func timingLine(st engine.Step) string {
+ return "took " + durationText(st.Elapsed) + " per call in this browser (mean of " + strconv.Itoa(st.Runs) + " calls)"
+}
+
+// durationText writes a duration with a unit a person reads at a glance.
+func durationText(d time.Duration) string {
+ switch {
+ case d < time.Microsecond:
+ return strconv.FormatInt(d.Nanoseconds(), 10) + " ns"
+ case d < time.Millisecond:
+ return strconv.FormatFloat(float64(d.Nanoseconds())/1e3, 'f', 1, 64) + " µs"
+ default:
+ return strconv.FormatFloat(float64(d.Nanoseconds())/1e6, 'f', 2, 64) + " ms"
+ }
+}
+
+// showCode publishes the whole pipeline in the chosen language.
+func (v *ViewModel) showCode() {
+ if v.CodeLang.Get() == LangNumPy {
+ v.Code.Set(v.session.PyProgram())
+ return
+ }
+ v.Code.Set(v.session.GoProgram())
+}
+
+func (v *ViewModel) copyCode() {
+ if v.Clipboard != nil {
+ v.Clipboard(v.Code.Get())
+ }
+ lang := "Go"
+ if v.CodeLang.Get() == LangNumPy {
+ lang = "NumPy"
+ }
+ v.Status.Set(lang + " program copied to the clipboard")
+}
+
+func (v *ViewModel) status() {
+ n := len(v.session.Steps)
+ s := strconv.Itoa(n) + " steps"
+ if n == 1 {
+ s = "1 step"
+ }
+ v.Status.Set(fmt.Sprintf("%s · go-ndarray %s compiled to WebAssembly (%s)", s, ndarrayVersion(), v.Runtime))
+}
+
+// ndarrayVersion is the go-ndarray version linked into this binary, read
+// from the build info so the status line cannot drift from go.mod.
+func ndarrayVersion() string {
+ return moduleVersion(debug.ReadBuildInfo)
+}
+
+func moduleVersion(read func() (*debug.BuildInfo, bool)) string {
+ if bi, ok := read(); ok {
+ for _, d := range bi.Deps {
+ if d.Path == "github.com/go-ndarray/ndarray" {
+ return d.Version
+ }
+ }
+ }
+ return "(unknown version)"
+}
+
+// SetRuntime replaces the runtime description of the status line.
+func (v *ViewModel) SetRuntime(s string) {
+ v.Runtime = s
+ v.status()
+}
diff --git a/playground/vm/vm_test.go b/playground/vm/vm_test.go
new file mode 100644
index 0000000..c275d0b
--- /dev/null
+++ b/playground/vm/vm_test.go
@@ -0,0 +1,240 @@
+// Copyright (c) the go-ndarray authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package vm
+
+import (
+ "runtime/debug"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/go-ndarray/go-ndarray.github.io/playground/engine"
+)
+
+func opIndex(t *testing.T, name string) int {
+ t.Helper()
+ for i, n := range engine.Ops {
+ if n.Name == name {
+ return i
+ }
+ }
+ t.Fatalf("no op %s", name)
+ return -1
+}
+
+func TestOpensOnTheREADMEExample(t *testing.T) {
+ v := New()
+ if v.Steps.Len() != 7 || v.Selected.Get() != 6 {
+ t.Fatalf("steps %d selected %d", v.Steps.Len(), v.Selected.Get())
+ }
+ if v.Steps.At(1) != "b = a.Reshape(2, -1) (2, 3)" || v.Steps.At(2) != "c = FromData([10, 20, 30]) (3,)" {
+ t.Errorf("rows: %q / %q", v.Steps.At(1), v.Steps.At(2))
+ }
+ if v.Title.Get() != "g — Add of b" || !strings.HasPrefix(v.Shape.Get(), "shape (2, 3) · 6 elements") {
+ t.Errorf("title %q shape %q", v.Title.Get(), v.Shape.Get())
+ }
+ if g := v.Grid.Get(); g == nil || g.Rows != 2 || g.Cols != 3 {
+ t.Errorf("grid %+v", g)
+ }
+ if v.OpNames[v.OpIndex.Get()] != "Reshape" || v.Args.Get() != "3, -1" || !strings.HasPrefix(v.Hint.Get(), "applies to the input") {
+ t.Errorf("form: %s %q %q", v.OpNames[v.OpIndex.Get()], v.Args.Get(), v.Hint.Get())
+ }
+ if v.Inputs.Len() != 7 || v.input() != "g" {
+ t.Errorf("inputs %v, input %q", v.Inputs.Slice(), v.input())
+ }
+ if !strings.HasPrefix(v.Code.Get(), "package main") || !strings.Contains(v.Status.Get(), "7 steps") {
+ t.Errorf("code/status: %q", v.Status.Get())
+ }
+ if !v.RemoveLast.CanExecute() || !v.CopyCode.CanExecute() || !v.Reset.CanExecute() {
+ t.Error("commands must be enabled with steps")
+ }
+}
+
+func TestApplyAndErrors(t *testing.T) {
+ v := New()
+ v.Selected.Set(1) // b becomes the input
+ if v.input() != "b" {
+ t.Fatalf("selecting b must make it the input, got %q", v.input())
+ }
+ v.OpIndex.Set(opIndex(t, "Transpose"))
+ if v.Args.Get() != "" {
+ t.Errorf("Transpose offers no arguments, got %q", v.Args.Get())
+ }
+ v.Apply.Execute()
+ if v.Steps.Len() != 8 || v.Selected.Get() != 7 || v.Title.Get() != "h — Transpose of b" {
+ t.Fatalf("after Apply: %d steps, selected %d, %q", v.Steps.Len(), v.Selected.Get(), v.Title.Get())
+ }
+ if !strings.Contains(v.Memory.Get(), "not contiguous") || !strings.Contains(v.Memory.Get(), "VIEW: shares memory with a, b, d, f") {
+ t.Errorf("memory %q", v.Memory.Get())
+ }
+ if !strings.HasPrefix(v.GoLine.Get(), "Go h := b.Transpose()") || v.PyLine.Get() != "NumPy h = b.T" {
+ t.Errorf("lines %q %q", v.GoLine.Get(), v.PyLine.Get())
+ }
+ v.OpIndex.Set(opIndex(t, "Reshape"))
+ v.Args.Set("4, 4")
+ v.Apply.Execute()
+ if !strings.HasPrefix(v.Error.Get(), "ndarray: shape mismatch") || v.Steps.Len() != 8 {
+ t.Errorf("error %q, %d steps", v.Error.Get(), v.Steps.Len())
+ }
+ v.Args.Set("-1")
+ v.Apply.Execute()
+ if v.Error.Get() != "" || v.Steps.Len() != 9 {
+ t.Errorf("a good apply clears the error: %q", v.Error.Get())
+ }
+ v.RemoveLast.Execute()
+ if v.Steps.Len() != 8 || v.Selected.Get() != 7 {
+ t.Errorf("RemoveLast: %d steps, selected %d", v.Steps.Len(), v.Selected.Get())
+ }
+}
+
+func TestResetAndEmpty(t *testing.T) {
+ v := New()
+ v.Reset.Execute()
+ if v.Steps.Len() != 0 || v.Selected.Get() != -1 || v.Grid.Get() != nil || v.Title.Get() != "No array yet" {
+ t.Fatalf("after reset: %d steps, %d, %q", v.Steps.Len(), v.Selected.Get(), v.Title.Get())
+ }
+ if v.RemoveLast.CanExecute() || v.CopyCode.CanExecute() || v.Reset.CanExecute() {
+ t.Error("commands must grey out with no steps")
+ }
+ if v.OpNames[v.OpIndex.Get()] != "Arange" || v.Args.Get() != "0, 12, 1" || !strings.HasPrefix(v.Hint.Get(), "creates a new array") {
+ t.Errorf("reset form: %q %q", v.Args.Get(), v.Hint.Get())
+ }
+ v.OpIndex.Set(opIndex(t, "Exp"))
+ v.Apply.Execute()
+ if !strings.Contains(v.Error.Get(), "needs an input") {
+ t.Errorf("error %q", v.Error.Get())
+ }
+ v.OpIndex.Set(opIndex(t, "Arange"))
+ v.Apply.Execute()
+ if v.Steps.Len() != 1 || v.Status.Get()[:6] != "1 step" {
+ t.Errorf("status %q", v.Status.Get())
+ }
+ // Reset while already on Arange re-offers its arguments.
+ v.Args.Set("zzz")
+ v.Reset.Execute()
+ if v.Args.Get() != "0, 12, 1" {
+ t.Errorf("args %q", v.Args.Get())
+ }
+}
+
+func TestPresetsAndCode(t *testing.T) {
+ v := New()
+ var copied string
+ v.Clipboard = func(s string) { copied = s }
+ for i, p := range engine.Presets {
+ v.PresetIndex.Set(i)
+ if v.Steps.Len() != len(p.Calls) || v.Error.Get() != "" {
+ t.Errorf("%s: %d steps, error %q", p.Name, v.Steps.Len(), v.Error.Get())
+ }
+ }
+ // The last preset proposes a step that fails.
+ if v.OpNames[v.OpIndex.Get()] != "Add" || v.input() != "b" || v.Args.Get() != "c" {
+ t.Errorf("proposed: %s %s %s", v.OpNames[v.OpIndex.Get()], v.input(), v.Args.Get())
+ }
+ v.Apply.Execute()
+ if !strings.HasPrefix(v.Error.Get(), "ndarray: shapes are not broadcastable") {
+ t.Errorf("error %q", v.Error.Get())
+ }
+ v.CopyCode.Execute()
+ if copied != v.Code.Get() || !strings.HasPrefix(copied, "package main") || v.Status.Get() != "Go program copied to the clipboard" {
+ t.Errorf("copy: %q", v.Status.Get())
+ }
+ v.CodeLang.Set(LangNumPy)
+ v.CopyCode.Execute()
+ if !strings.HasPrefix(copied, "import numpy as np") || v.Status.Get() != "NumPy program copied to the clipboard" {
+ t.Errorf("NumPy copy: %q", v.Status.Get())
+ }
+ v.Clipboard = nil
+ v.CopyCode.Execute() // no host clipboard: no panic
+ v.SetRuntime("js/wasm")
+ if !strings.HasSuffix(v.Status.Get(), "(js/wasm)") {
+ t.Error(v.Status.Get())
+ }
+ // Out-of-range menu indices fall back to the first entry.
+ v.PresetIndex.Set(99)
+ if v.Steps.Len() != len(engine.Presets[0].Calls) {
+ t.Error("preset 99 must load the first")
+ }
+ v.OpIndex.Set(-5)
+ if v.op().Name != "Arange" {
+ t.Error("op -5 must be the first")
+ }
+ v.InputIndex.Set(99)
+ if v.input() != "" {
+ t.Error("input 99 is none")
+ }
+ v.Selected.Set(42) // nothing selected: blank details, input untouched
+ if v.Title.Get() != "No array yet" {
+ t.Error(v.Title.Get())
+ }
+}
+
+func TestDetailLines(t *testing.T) {
+ v := New()
+ v.PresetIndex.Set(3) // reductions: e = b.Max() is a number, f = ArgMax along 1
+ v.Selected.Set(4)
+ if !strings.Contains(v.Shape.Get(), "plain float64") || !strings.Contains(v.Memory.Get(), "no strides") {
+ t.Errorf("scalar: %q / %q", v.Shape.Get(), v.Memory.Get())
+ }
+ before := v.input()
+ v.Selected.Set(4) // a number is not an input
+ if v.input() != before {
+ t.Error("a scalar step must not become the input")
+ }
+ v.session.Steps[4].Op = "ArgMax"
+ v.showSelected()
+ if !strings.Contains(v.Shape.Get(), "plain int") {
+ t.Error(v.Shape.Get())
+ }
+ v.PresetIndex.Set(1) // views: d = b[1:3, ::2] has an offset
+ v.Selected.Set(3)
+ if !strings.Contains(v.Memory.Get(), "offset 6") {
+ t.Error(v.Memory.Get())
+ }
+ for d, want := range map[time.Duration]string{
+ 500 * time.Nanosecond: "500 ns", 1500 * time.Nanosecond: "1.5 µs", 2500 * time.Microsecond: "2.50 ms",
+ } {
+ if got := durationText(d); got != want {
+ t.Errorf("durationText(%v) = %q, want %q", d, got, want)
+ }
+ }
+ if v.Session() == nil {
+ t.Error("Session")
+ }
+}
+
+func TestSelectOpUnknownInput(t *testing.T) {
+ v := New()
+ v.selectOp("Transpose", "nope")
+ if v.OpNames[v.OpIndex.Get()] != "Transpose" {
+ t.Error("selectOp")
+ }
+}
+
+// A preset whose call fails keeps the steps before it and shows the error.
+func TestBrokenPreset(t *testing.T) {
+ saved := engine.Presets
+ defer func() { engine.Presets = saved }()
+ engine.Presets = append(append([]engine.Preset(nil), saved...), engine.Preset{Name: "broken",
+ Calls: []engine.Call{{Op: "Arange", Args: "0, 2, 1"}, {Op: "Reshape", Input: "a", Args: "3"}}})
+ v := New()
+ v.PresetIndex.Set(len(engine.Presets) - 1)
+ if v.Steps.Len() != 1 || !strings.HasPrefix(v.Error.Get(), "ndarray: shape mismatch") {
+ t.Errorf("%d steps, error %q", v.Steps.Len(), v.Error.Get())
+ }
+}
+
+func TestModuleVersion(t *testing.T) {
+ bi := &debug.BuildInfo{Deps: []*debug.Module{{Path: "x"}, {Path: "github.com/go-ndarray/ndarray", Version: "v9.9.9"}}}
+ if moduleVersion(func() (*debug.BuildInfo, bool) { return bi, true }) != "v9.9.9" {
+ t.Error("version from build info")
+ }
+ if moduleVersion(func() (*debug.BuildInfo, bool) { return nil, false }) != "(unknown version)" {
+ t.Error("no build info")
+ }
+ if moduleVersion(func() (*debug.BuildInfo, bool) { return &debug.BuildInfo{}, true }) != "(unknown version)" {
+ t.Error("not linked")
+ }
+ _ = ndarrayVersion()
+}