From f7140320d99e768cfe3bc8db2bf8b37b5234e45b Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 8 Oct 2026 10:08:40 +0200 Subject: [PATCH 1/6] playground: go-ndarray compiled to WebAssembly, a go-widgets canvas app (WIP) Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 3 + content/playground.md | 4 + layouts/_default/playground.html | 141 +++++ layouts/index.html | 2 +- playground/app.go | 389 ++++++++++++ playground/capture_test.go | 68 +++ playground/cmd/playground-wasm/main.go | 224 +++++++ playground/cmd/playground-wasm/stub.go | 11 + playground/debug.go | 54 ++ playground/engine/args.go | 143 +++++ playground/engine/inspect.go | 176 ++++++ playground/engine/ops.go | 797 +++++++++++++++++++++++++ playground/engine/presets.go | 95 +++ playground/engine/session.go | 207 +++++++ playground/events.go | 103 ++++ playground/go.mod | 41 ++ playground/go.sum | 74 +++ playground/look_test.go | 23 + playground/view_binding.go | 86 +++ playground/vm/vm.go | 381 ++++++++++++ 20 files changed, 3021 insertions(+), 1 deletion(-) create mode 100644 content/playground.md create mode 100644 layouts/_default/playground.html create mode 100644 playground/app.go create mode 100644 playground/capture_test.go create mode 100644 playground/cmd/playground-wasm/main.go create mode 100644 playground/cmd/playground-wasm/stub.go create mode 100644 playground/debug.go create mode 100644 playground/engine/args.go create mode 100644 playground/engine/inspect.go create mode 100644 playground/engine/ops.go create mode 100644 playground/engine/presets.go create mode 100644 playground/engine/session.go create mode 100644 playground/events.go create mode 100644 playground/go.mod create mode 100644 playground/go.sum create mode 100644 playground/look_test.go create mode 100644 playground/view_binding.go create mode 100644 playground/vm/vm.go 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..b8f6d96 --- /dev/null +++ b/layouts/_default/playground.html @@ -0,0 +1,141 @@ + + + + + +Playground — {{ .Site.Title }} + + +{{ partial "styles.html" . }} + + + + + +
+ +
+
Downloading go-ndarray compiled to WebAssembly…
+
+
0%
+
+
+
+ + +{{/* 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/app.go b/playground/app.go new file mode 100644 index 0000000..30ec44a --- /dev/null +++ b/playground/app.go @@ -0,0 +1,389 @@ +// 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) + _ = toolkit.UseOpenTypeTextSize(int(float64(baseFontPx)*scale + 0.5)) +} + +// 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 + + root, body, left, right *box + 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() +} + +// box is a VBox or an HBox: the body flips between the two with the width. +type box struct { + toolkit.Widget + add func(w toolkit.Widget, fixed, flex int) +} + +func newVBox(spacing int) *box { + b := toolkit.NewVBox() + b.Spacing = spacing + return &box{Widget: b, add: func(w toolkit.Widget, fixed, flex int) { + if flex > 0 { + b.AddFlex(w, flex) + } else { + b.AddFixed(w, fixed) + } + }} +} + +func newHBox(spacing int) *box { + b := toolkit.NewHBox() + b.Spacing = spacing + return &box{Widget: b, add: func(w toolkit.Widget, fixed, flex int) { + if flex > 0 { + b.AddFlex(w, flex) + } else { + b.AddFixed(w, fixed) + } + }} +} + +// 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 = toolkit.NewHBox() + s.top.Spacing = sc(gap) + s.top.AddFlex(s.title, 1) + s.top.AddFixed(s.presets, sc(240)) + s.top.AddFixed(s.reset, sc(80)) + + s.formBtns = toolkit.NewHBox() + s.formBtns.Spacing = sc(gap) + s.formBtns.AddFlex(s.apply, 1) + s.formBtns.AddFlex(s.undo, 1) + + s.left = newVBox(sc(gap / 2)) + s.left.add(s.pipeLbl, sc(labelH), 0) + s.left.add(s.steps, 0, 1) + s.left.add(s.opLbl, sc(labelH), 0) + s.left.add(s.ops, sc(rowH), 0) + s.left.add(s.inLbl, sc(labelH), 0) + s.left.add(s.inputs, sc(rowH), 0) + s.left.add(s.hint, sc(labelH), 0) + s.left.add(s.args, sc(rowH), 0) + s.left.add(s.formBtns, sc(rowH+2), 0) + s.left.add(s.errLabel, sc(labelH), 0) + + s.codeBar = toolkit.NewHBox() + s.codeBar.Spacing = sc(gap) + s.codeBar.AddFlex(s.lang, 1) + s.codeBar.AddFixed(s.copyB, sc(90)) + + s.right = newVBox(sc(2)) + s.right.add(s.stTitle, sc(labelH+4), 0) + s.right.add(s.shape, sc(labelH), 0) + s.right.add(s.memory, sc(labelH), 0) + s.right.add(s.timing, sc(labelH), 0) + s.right.add(s.goLine, sc(labelH), 0) + s.right.add(s.pyLine, sc(labelH), 0) + s.right.add(s.note, sc(labelH), 0) + s.right.add(s.table, 0, 1) + s.right.add(s.codeBar, sc(rowH+2), 0) + s.right.add(s.code, sc(codeH), 0) + + if narrow { + s.body = newVBox(sc(gap * 2)) + s.body.add(s.left, sc(stackedTop), 0) + s.body.add(s.right, 0, 1) + } else { + s.body = newHBox(sc(gap * 2)) + s.body.add(s.left, sc(leftW), 0) + s.body.add(s.right, 0, 1) + } + s.root = newVBox(sc(gap)) + s.root.add(s.top, sc(barH), 0) + s.root.add(s.body, 0, 1) + s.root.add(s.status, sc(labelH), 0) + 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/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..7619866 --- /dev/null +++ b/playground/debug.go @@ -0,0 +1,54 @@ +// Copyright (c) the go-ndarray authors. +// SPDX-License-Identifier: BSD-3-Clause + +package playground + +import "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"` +} + +// 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(), + } + 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/inspect.go b/playground/engine/inspect.go new file mode 100644 index 0000000..a917e23 --- /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.0 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..405d0a5 --- /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.0 + 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.326.1-0.20261008080025-9d9c6286abc2 +) + +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..e04f437 --- /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.0 h1:0UESK3wd4ysMga4zo7aDYQlkJNW40GJbmTTEr5ssOB4= +github.com/go-ndarray/ndarray v0.7.0/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.326.1-0.20261008080025-9d9c6286abc2 h1:9hKqXm4uCPdFdPltYhfqyPgW2M4tIE37LYWGxQwBnEI= +github.com/go-widgets/toolkit v0.326.1-0.20261008080025-9d9c6286abc2/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/look_test.go b/playground/look_test.go new file mode 100644 index 0000000..2a18242 --- /dev/null +++ b/playground/look_test.go @@ -0,0 +1,23 @@ +package playground + +import ( + "os" + "testing" +) + +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} { + SetupText(1) + s := NewState(1280, 800, dark) + buf := make([]byte, 1280*800*4) + s.Draw(buf) + name := "light.png" + if dark { + name = "dark.png" + } + savePNG(t, name, buf, 1280, 800) + } +} 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..a7ad0c4 --- /dev/null +++ b/playground/vm/vm.go @@ -0,0 +1,381 @@ +// 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" + "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.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) +} + +// 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 v0.7.0 compiled to WebAssembly (%s)", s, v.Runtime)) +} + +// SetRuntime replaces the runtime description of the status line. +func (v *ViewModel) SetRuntime(s string) { + v.Runtime = s + v.status() +} From e539702f40187445554a8527a2d523c94ae15793 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 8 Oct 2026 10:13:46 +0200 Subject: [PATCH 2/6] playground: browser proof (light/dark, 1x/2x), selection drives the input, text at logical size Co-Authored-By: Claude Opus 5.5 (1M context) --- playground/app.go | 118 ++++++++--------- playground/browser_test.go | 116 ++++++++++++++++ playground/browsertest/driver.cjs | 211 ++++++++++++++++++++++++++++++ playground/debug.go | 14 +- playground/vm/vm.go | 17 +++ 5 files changed, 410 insertions(+), 66 deletions(-) create mode 100644 playground/browser_test.go create mode 100644 playground/browsertest/driver.cjs diff --git a/playground/app.go b/playground/app.go index 30ec44a..5c79b9b 100644 --- a/playground/app.go +++ b/playground/app.go @@ -41,7 +41,9 @@ func SetupText(scale float64) { scale = 1 } toolkit.SetMetricScale(scale) - _ = toolkit.UseOpenTypeTextSize(int(float64(baseFontPx)*scale + 0.5)) + // 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 @@ -85,8 +87,11 @@ type State struct { status *toolkit.Label - root, body, left, right *box - formBtns, codeBar, top *toolkit.HBox + // 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 @@ -98,34 +103,18 @@ type State struct { unbind []func() } -// box is a VBox or an HBox: the body flips between the two with the width. -type box struct { - toolkit.Widget - add func(w toolkit.Widget, fixed, flex int) -} - -func newVBox(spacing int) *box { +// vbox makes a VBox with the given spacing. +func vbox(spacing int) *toolkit.VBox { b := toolkit.NewVBox() b.Spacing = spacing - return &box{Widget: b, add: func(w toolkit.Widget, fixed, flex int) { - if flex > 0 { - b.AddFlex(w, flex) - } else { - b.AddFixed(w, fixed) - } - }} + return b } -func newHBox(spacing int) *box { +// hbox makes an HBox with the given spacing. +func hbox(spacing int) *toolkit.HBox { b := toolkit.NewHBox() b.Spacing = spacing - return &box{Widget: b, add: func(w toolkit.Widget, fixed, flex int) { - if flex > 0 { - b.AddFlex(w, flex) - } else { - b.AddFixed(w, fixed) - } - }} + return b } // NewState builds the View over a fresh ViewModel, w×h device pixels. @@ -218,59 +207,58 @@ func (s *State) layout() { sc := toolkit.Scaled narrow := s.w < sc(narrowW) - s.top = toolkit.NewHBox() - s.top.Spacing = sc(gap) + s.top = hbox(sc(gap)) s.top.AddFlex(s.title, 1) - s.top.AddFixed(s.presets, sc(240)) + s.top.AddFixed(s.presets, sc(260)) s.top.AddFixed(s.reset, sc(80)) - s.formBtns = toolkit.NewHBox() - s.formBtns.Spacing = sc(gap) + s.formBtns = hbox(sc(gap)) s.formBtns.AddFlex(s.apply, 1) s.formBtns.AddFlex(s.undo, 1) - s.left = newVBox(sc(gap / 2)) - s.left.add(s.pipeLbl, sc(labelH), 0) - s.left.add(s.steps, 0, 1) - s.left.add(s.opLbl, sc(labelH), 0) - s.left.add(s.ops, sc(rowH), 0) - s.left.add(s.inLbl, sc(labelH), 0) - s.left.add(s.inputs, sc(rowH), 0) - s.left.add(s.hint, sc(labelH), 0) - s.left.add(s.args, sc(rowH), 0) - s.left.add(s.formBtns, sc(rowH+2), 0) - s.left.add(s.errLabel, sc(labelH), 0) - - s.codeBar = toolkit.NewHBox() - s.codeBar.Spacing = sc(gap) + 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 = newVBox(sc(2)) - s.right.add(s.stTitle, sc(labelH+4), 0) - s.right.add(s.shape, sc(labelH), 0) - s.right.add(s.memory, sc(labelH), 0) - s.right.add(s.timing, sc(labelH), 0) - s.right.add(s.goLine, sc(labelH), 0) - s.right.add(s.pyLine, sc(labelH), 0) - s.right.add(s.note, sc(labelH), 0) - s.right.add(s.table, 0, 1) - s.right.add(s.codeBar, sc(rowH+2), 0) - s.right.add(s.code, sc(codeH), 0) + 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 { - s.body = newVBox(sc(gap * 2)) - s.body.add(s.left, sc(stackedTop), 0) - s.body.add(s.right, 0, 1) + b := vbox(sc(gap * 2)) + b.AddFixed(s.left, sc(stackedTop)) + b.AddFlex(s.right, 1) + s.body = b } else { - s.body = newHBox(sc(gap * 2)) - s.body.add(s.left, sc(leftW), 0) - s.body.add(s.right, 0, 1) + b := hbox(sc(gap * 2)) + b.AddFixed(s.left, sc(leftW)) + b.AddFlex(s.right, 1) + s.body = b } - s.root = newVBox(sc(gap)) - s.root.add(s.top, sc(barH), 0) - s.root.add(s.body, 0, 1) - s.root.add(s.status, sc(labelH), 0) + 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 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/debug.go b/playground/debug.go index 7619866..41f40fc 100644 --- a/playground/debug.go +++ b/playground/debug.go @@ -3,7 +3,11 @@ package playground -import "github.com/go-widgets/toolkit" +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. @@ -23,6 +27,12 @@ type Debug struct { 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. @@ -34,6 +44,8 @@ func (s *State) Debug() Debug { 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) diff --git a/playground/vm/vm.go b/playground/vm/vm.go index a7ad0c4..b1f1add 100644 --- a/playground/vm/vm.go +++ b/playground/vm/vm.go @@ -119,6 +119,7 @@ func New() *ViewModel { 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) @@ -257,6 +258,22 @@ func stepRow(st engine.Step) string { 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() From e61b4c25a9cad493cd3a8f2b133f5ea34411bad6 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 8 Oct 2026 10:18:50 +0200 Subject: [PATCH 3/6] playground: tests at 100% (engine, vm, view), wasm goroutine check, CI, deploy wiring, README Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 6 + .github/workflows/hugo.yml | 24 ++ .github/workflows/playground.yml | 131 +++++++ playground/README.md | 72 ++++ playground/app_test.go | 310 +++++++++++++++ playground/engine/engine_test.go | 458 ++++++++++++++++++++++ playground/engine/goroutines_test.go | 51 +++ playground/hack/lint-negative-controls.sh | 48 +++ playground/look_test.go | 23 -- playground/vm/vm_test.go | 225 +++++++++++ 10 files changed, 1325 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/playground.yml create mode 100644 playground/README.md create mode 100644 playground/app_test.go create mode 100644 playground/engine/engine_test.go create mode 100644 playground/engine/goroutines_test.go create mode 100755 playground/hack/lint-negative-controls.sh delete mode 100644 playground/look_test.go create mode 100644 playground/vm/vm_test.go 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..68c8810 --- /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 ./... + 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/playground/README.md b/playground/README.md new file mode 100644 index 0000000..982ace9 --- /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.0; `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_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/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/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/look_test.go b/playground/look_test.go deleted file mode 100644 index 2a18242..0000000 --- a/playground/look_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package playground - -import ( - "os" - "testing" -) - -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} { - SetupText(1) - s := NewState(1280, 800, dark) - buf := make([]byte, 1280*800*4) - s.Draw(buf) - name := "light.png" - if dark { - name = "dark.png" - } - savePNG(t, name, buf, 1280, 800) - } -} diff --git a/playground/vm/vm_test.go b/playground/vm/vm_test.go new file mode 100644 index 0000000..b582766 --- /dev/null +++ b/playground/vm/vm_test.go @@ -0,0 +1,225 @@ +// Copyright (c) the go-ndarray authors. +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "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()) + } +} From 9e942fa0438fff80d57ea5c4b3bc623d02152c57 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 8 Oct 2026 19:12:21 +0200 Subject: [PATCH 4/6] playground: go-ndarray v0.7.1, version read from build info; coverage over the tested packages Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/playground.yml | 2 +- playground/README.md | 2 +- playground/engine/inspect.go | 2 +- playground/go.mod | 2 +- playground/go.sum | 4 ++-- playground/vm/vm.go | 20 +++++++++++++++++++- playground/vm/vm_test.go | 15 +++++++++++++++ 7 files changed, 40 insertions(+), 7 deletions(-) diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index 68c8810..1deea6a 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -52,7 +52,7 @@ jobs: run: go test -race -count=1 ./... - name: 100% statement coverage run: | - go test -count=1 -coverprofile=coverage.out ./... + 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; } diff --git a/playground/README.md b/playground/README.md index 982ace9..edef08a 100644 --- a/playground/README.md +++ b/playground/README.md @@ -30,7 +30,7 @@ Widgets used: `Label`, `DropDown`, `ListBox`, `Entry`, `Button`, `Table.CellFill` with `toolkit.Viridis`, added to go-widgets/toolkit for this page. -Strides are not exported by go-ndarray v0.7.0; `engine.LayoutOf` reads the +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. diff --git a/playground/engine/inspect.go b/playground/engine/inspect.go index a917e23..d9055bb 100644 --- a/playground/engine/inspect.go +++ b/playground/engine/inspect.go @@ -15,7 +15,7 @@ import ( // 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.0 exports neither the strides nor the storage, so Layout +// 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 diff --git a/playground/go.mod b/playground/go.mod index 405d0a5..e75f0ad 100644 --- a/playground/go.mod +++ b/playground/go.mod @@ -3,7 +3,7 @@ module github.com/go-ndarray/go-ndarray.github.io/playground go 1.27.1 require ( - github.com/go-ndarray/ndarray v0.7.0 + 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 diff --git a/playground/go.sum b/playground/go.sum index e04f437..303d58d 100644 --- a/playground/go.sum +++ b/playground/go.sum @@ -22,8 +22,8 @@ github.com/go-images/jpeg2000 v0.1.0 h1:SiFAZ6NGvGLU+VmxKji74RiSQDeK6TyjUZr1wjlx 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.0 h1:0UESK3wd4ysMga4zo7aDYQlkJNW40GJbmTTEr5ssOB4= -github.com/go-ndarray/ndarray v0.7.0/go.mod h1:MI/3teJK5hMJ4Chz6Auy2w+DnQAjtbekhHRg+NUbNMI= +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= diff --git a/playground/vm/vm.go b/playground/vm/vm.go index b1f1add..f424728 100644 --- a/playground/vm/vm.go +++ b/playground/vm/vm.go @@ -10,6 +10,7 @@ package vm import ( "fmt" "runtime" + "runtime/debug" "strconv" "strings" "time" @@ -388,7 +389,24 @@ func (v *ViewModel) status() { if n == 1 { s = "1 step" } - v.Status.Set(fmt.Sprintf("%s · go-ndarray v0.7.0 compiled to WebAssembly (%s)", s, v.Runtime)) + 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. diff --git a/playground/vm/vm_test.go b/playground/vm/vm_test.go index b582766..c275d0b 100644 --- a/playground/vm/vm_test.go +++ b/playground/vm/vm_test.go @@ -4,6 +4,7 @@ package vm import ( + "runtime/debug" "strings" "testing" "time" @@ -223,3 +224,17 @@ func TestBrokenPreset(t *testing.T) { 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() +} From 349c960ab37cf502b37ff6503aeee199eec9a279 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 8 Oct 2026 19:16:40 +0200 Subject: [PATCH 5/6] playground page: no bare scheme in the load-error hint (the link check read it as a URL) Co-Authored-By: Claude Opus 5.5 (1M context) --- layouts/_default/playground.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/layouts/_default/playground.html b/layouts/_default/playground.html index b8f6d96..de242c3 100644 --- a/layouts/_default/playground.html +++ b/layouts/_default/playground.html @@ -93,7 +93,7 @@ function fail(e){ $("pg-msg").textContent="The playground failed to load."; var err=$("pg-err"); - err.textContent=String(e)+(location.protocol==="file:"?"\n(open the page over http://, file:// cannot fetch the wasm)":"\n(a hard refresh may help if a stale copy is cached)"); + err.textContent=String(e)+(location.protocol==="file:"?"\n(serve the page over HTTP: a page opened from a file cannot fetch the wasm)":"\n(a hard refresh may help if a stale copy is cached)"); err.classList.add("show"); } function fetchWasm(url){ From e949de6cf7714efbb9b906884311e25c41e8c814 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 8 Oct 2026 19:27:21 +0200 Subject: [PATCH 6/6] playground: pin go-widgets/toolkit v0.328.0 (Table.CellFill, Viridis, Entry caret clamp) instead of the PR pseudo-version Co-Authored-By: Claude Opus 5.5 (1M context) --- playground/go.mod | 2 +- playground/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/playground/go.mod b/playground/go.mod index e75f0ad..655a008 100644 --- a/playground/go.mod +++ b/playground/go.mod @@ -7,7 +7,7 @@ require ( 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.326.1-0.20261008080025-9d9c6286abc2 + github.com/go-widgets/toolkit v0.328.0 ) require ( diff --git a/playground/go.sum b/playground/go.sum index 303d58d..cfb7a4d 100644 --- a/playground/go.sum +++ b/playground/go.sum @@ -40,8 +40,8 @@ github.com/go-widgets/mvvmtk v0.14.1 h1:JMuOXLylXDd0uU5ca9NOHUhStgPy5DNtSxLrYzVG 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.326.1-0.20261008080025-9d9c6286abc2 h1:9hKqXm4uCPdFdPltYhfqyPgW2M4tIE37LYWGxQwBnEI= -github.com/go-widgets/toolkit v0.326.1-0.20261008080025-9d9c6286abc2/go.mod h1:IfzBIAPNK6m9FGsEGgaEefyx8DZ1441HYP1WK8n74pM= +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=