feat: initial release
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-03 10:00:00 +02:00
commit af4ee19703
617 changed files with 191195 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package plot
import (
"math"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// The dtype census for plot: the series surface, probed with Bool, the
// narrow integers and the Int anchor against a float64 baseline
// carrying exactly the widened probe values. Line reads both axes
// through the widening accessors by design, so every numeric dtype
// plots exactly what Int plots.
var ptDtypes = []core.Dtype{core.Bool, core.Int8, core.Uint8, core.Int16, core.Uint16, core.Int32, core.Uint32, core.Int}
type ptMaker func(vals []float64, shape ...int) *core.Array
func ptCast(dt core.Dtype, v float64) float64 {
switch dt {
case core.Bool:
if v != 0 {
return 1
}
return 0
case core.Int8:
return float64(int8(int64(v)))
case core.Uint8:
return float64(uint8(int64(v)))
case core.Int16:
return float64(int16(int64(v)))
case core.Uint16:
return float64(uint16(int64(v)))
case core.Int32:
return float64(int32(int64(v)))
case core.Uint32:
return float64(uint32(int64(v)))
case core.Int:
return float64(int64(v))
default:
return v
}
}
func ptMakers(t *testing.T, dt core.Dtype) (probe, base ptMaker) {
t.Helper()
castOf := func(vals []float64) []float64 {
out := make([]float64, len(vals))
for i, v := range vals {
out[i] = ptCast(dt, v)
}
return out
}
probe = func(vals []float64, shape ...int) *core.Array {
cast := castOf(vals)
var a *core.Array
var err error
switch dt {
case core.Bool:
bs := make([]bool, len(cast))
for i, v := range cast {
bs[i] = v != 0
}
a, err = core.FromBools(bs, shape...)
case core.Int8:
vs := make([]int8, len(cast))
for i, v := range cast {
vs[i] = int8(int64(v))
}
a, err = core.FromInt8s(vs, shape...)
case core.Uint8:
vs := make([]uint8, len(cast))
for i, v := range cast {
vs[i] = uint8(int64(v))
}
a, err = core.FromUint8s(vs, shape...)
case core.Int16:
vs := make([]int16, len(cast))
for i, v := range cast {
vs[i] = int16(int64(v))
}
a, err = core.FromInt16s(vs, shape...)
case core.Uint16:
vs := make([]uint16, len(cast))
for i, v := range cast {
vs[i] = uint16(int64(v))
}
a, err = core.FromUint16s(vs, shape...)
case core.Int32:
vs := make([]int32, len(cast))
for i, v := range cast {
vs[i] = int32(int64(v))
}
a, err = core.FromInt32s(vs, shape...)
case core.Uint32:
vs := make([]uint32, len(cast))
for i, v := range cast {
vs[i] = uint32(int64(v))
}
a, err = core.FromUint32s(vs, shape...)
case core.Int:
vs := make([]int64, len(cast))
for i, v := range cast {
vs[i] = int64(v)
}
a, err = core.FromInts(vs, shape...)
default:
a, err = core.FromFloats(cast, shape...)
}
if err != nil {
t.Fatalf("probe maker (%s): %v", dt, err)
}
return a
}
base = func(vals []float64, shape ...int) *core.Array {
a, err := core.FromFloats(castOf(vals), shape...)
if err != nil {
t.Fatalf("baseline maker: %v", err)
}
return a
}
return probe, base
}
// TestDtypesCensusPlot pins Line on every probe dtype against the
// float64 baseline of the same widened values: identical points, no
// panic, and the existing non-finite refusal preserved.
func TestDtypesCensusPlot(t *testing.T) {
xs := []float64{0, 1, 2, 3, 4}
ys := []float64{3, 1, 4, 1, 5}
for _, dt := range ptDtypes {
t.Run("Line/"+dt.String(), func(t *testing.T) {
probe, base := ptMakers(t, dt)
ps, perr := Line("s", probe(xs, 5), probe(ys, 5))
bs, berr := Line("s", base(xs, 5), base(ys, 5))
if berr != nil {
t.Fatalf("Line float baseline: %v", berr)
}
if perr != nil {
t.Fatalf("Line(%s): %v; the float baseline of the same values succeeded", dt, perr)
}
if ps.Name != bs.Name || len(ps.Points) != len(bs.Points) {
t.Fatalf("Line(%s): series %q with %d points, want %q with %d", dt, ps.Name, len(ps.Points), bs.Name, len(bs.Points))
}
for i := range ps.Points {
if ps.Points[i] != bs.Points[i] {
t.Fatalf("Line(%s): point %d = %+v, want %+v", dt, i, ps.Points[i], bs.Points[i])
}
}
})
}
// The standing refusals keep their wording for the new dtypes too.
t.Run("Line refusals", func(t *testing.T) {
x8, err := core.FromInt8s([]int8{0, 1, 2, 3, 4}, 5)
if err != nil {
t.Fatal(err)
}
y8, err := core.FromInt8s([]int8{3, 1, 4, 1}, 4)
if err != nil {
t.Fatal(err)
}
if _, err := Line("s", x8, y8); err == nil {
t.Fatal("Line accepted a length mismatch")
}
empty, _ := core.FromInt8s(nil, 0)
if _, err := Line("s", empty, empty); err == nil {
t.Fatal("Line accepted empty arrays")
}
okX, _ := core.FromFloats([]float64{0, 1, 2}, 3)
nanY, _ := core.FromFloats([]float64{1, math.NaN(), 1}, 3)
if _, err := Line("s", okX, nanY); err == nil {
t.Fatal("Line accepted a non-finite point")
}
})
}
+240
View File
@@ -0,0 +1,240 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Package plot draws the deterministic SVG line charts a scientific
// paper needs: linear axes, five ticks each, one legend line per
// series, and nothing else. The output is deterministic by contract:
// the same chart always renders byte for byte the same file, so a
// figure in a paper can be regenerated and compared exactly like any
// other computed number. The package is small by intent; it draws the
// figures, it does not stage a cinema.
package plot
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Point is one data point in axis units.
type Point struct {
X, Y float64
}
// Series is one named polyline.
type Series struct {
Name string
Points []Point
}
// Chart is a linear-axis line chart.
type Chart struct {
Title string
XLabel string
YLabel string
Width int
Height int
Series []Series
// XRange and YRange are optional; a zero, inverted or non-finite
// span falls back to the data's own bounds.
XRange [2]float64
YRange [2]float64
}
// Line returns a series joining the points (xs[i], ys[i]) of two
// arrays. Both arrays must be rank 1, of equal, non-zero length, and
// hold only finite numbers; the values are read through the promotion
// ladder, so any numeric dtype is accepted.
func Line(name string, xs, ys *core.Array) (Series, error) {
const op = "Plot"
if xs == nil || ys == nil {
return Series{}, base.Errf("%s: a nil array cannot make a series", op)
}
if xs.NDim() != 1 {
return Series{}, base.Errf("%s: the x values must be rank 1, got shape %s", op, base.ShapeText(xs.Shape()))
}
if ys.NDim() != 1 {
return Series{}, base.Errf("%s: the y values must be rank 1, got shape %s", op, base.ShapeText(ys.Shape()))
}
if xs.Len() != ys.Len() {
return Series{}, base.Errf("%s: length mismatch, %d points of x against %d points of y",
op, xs.Len(), ys.Len())
}
if xs.Len() == 0 {
return Series{}, base.Errf("%s: an empty array cannot make a series", op)
}
pts := make([]Point, xs.Len())
for i := range pts {
x, y := xs.FloatAt(i), ys.FloatAt(i)
if math.IsNaN(x) || math.IsInf(x, 0) || math.IsNaN(y) || math.IsInf(y, 0) {
return Series{}, base.Errf("%s: non-finite point at index %d: (%g, %g)", op, i, x, y)
}
pts[i] = Point{X: x, Y: y}
}
return Series{Name: name, Points: pts}, nil
}
// WriteSVG renders the chart into path. The output is deterministic:
// the same chart always renders byte for byte the same file. Every
// point of every series must be finite, the contract the Line
// constructor enforces on the caller's behalf and this entry point
// enforces for a series built by hand.
func (c Chart) WriteSVG(path string) error {
w := c.Width
if w <= 0 {
w = 720
}
h := c.Height
if h <= 0 {
h = 460
}
const (
left = 64.0
right = 16.0
top = 40.0
bottom = 52.0
)
all := make([]Point, 0, 256)
for si, s := range c.Series {
for pi, p := range s.Points {
if math.IsNaN(p.X) || math.IsInf(p.X, 0) || math.IsNaN(p.Y) || math.IsInf(p.Y, 0) {
return base.Errf("Plot: series %d (%s) holds the non-finite point %d: (%g, %g)", si, s.Name, pi, p.X, p.Y)
}
}
all = append(all, s.Points...)
}
if len(all) < 2 {
return base.Errf("Plot: the chart needs at least two points, has %d", len(all))
}
xr := c.XRange
if !(xr[0] < xr[1]) || math.IsInf(xr[0], 0) || math.IsInf(xr[1], 0) {
xr = bounds(all, true)
}
yr := c.YRange
if !(yr[0] < yr[1]) || math.IsInf(yr[0], 0) || math.IsInf(yr[1], 0) {
yr = bounds(all, false)
}
px := func(x float64) float64 {
return left + (x-xr[0])/(xr[1]-xr[0])*(float64(w)-left-right)
}
py := func(y float64) float64 {
return float64(h) - bottom - (y-yr[0])/(yr[1]-yr[0])*(float64(h)-top-bottom)
}
var b strings.Builder
b.WriteString(xmlHeader)
fmt.Fprintf(&b, "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%d\" height=\"%d\" viewBox=\"0 0 %d %d\">\n", w, h, w, h)
fmt.Fprintf(&b, "<rect width=\"%d\" height=\"%d\" fill=\"white\"/>\n", w, h)
fmt.Fprintf(&b, "<text x=\"%g\" y=\"24\" font-family=\"sans-serif\" font-size=\"15\" fill=\"#111\">%s</text>\n",
left, esc(c.Title))
// Axes with five ticks each.
for k := range 5 {
t := xr[0] + (xr[1]-xr[0])*float64(k)/4
x := px(t)
fmt.Fprintf(&b, "<line x1=\"%g\" y1=\"%g\" x2=\"%g\" y2=\"%g\" stroke=\"#ccc\" stroke-width=\"1\"/>\n",
x, top, x, float64(h)-bottom)
fmt.Fprintf(&b, "<text x=\"%g\" y=\"%g\" font-family=\"sans-serif\" font-size=\"11\" fill=\"#333\" text-anchor=\"middle\">%s</text>\n",
x, float64(h)-bottom+16, tick(t))
}
for k := range 5 {
t := yr[0] + (yr[1]-yr[0])*float64(k)/4
y := py(t)
fmt.Fprintf(&b, "<line x1=\"%g\" y1=\"%g\" x2=\"%g\" y2=\"%g\" stroke=\"#ccc\" stroke-width=\"1\"/>\n",
left, y, float64(w)-right, y)
fmt.Fprintf(&b, "<text x=\"%g\" y=\"%g\" font-family=\"sans-serif\" font-size=\"11\" fill=\"#333\" text-anchor=\"end\">%s</text>\n",
left-6, y+4, tick(t))
}
fmt.Fprintf(&b, "<line x1=\"%g\" y1=\"%g\" x2=\"%g\" y2=\"%g\" stroke=\"#111\" stroke-width=\"1\"/>\n",
left, float64(h)-bottom, float64(w)-right, float64(h)-bottom)
fmt.Fprintf(&b, "<line x1=\"%g\" y1=\"%g\" x2=\"%g\" y2=\"%g\" stroke=\"#111\" stroke-width=\"1\"/>\n",
left, top, left, float64(h)-bottom)
fmt.Fprintf(&b, "<text x=\"%g\" y=\"%g\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#111\" text-anchor=\"middle\">%s</text>\n",
(left+float64(w)-right)/2, float64(h)-12, esc(c.XLabel))
fmt.Fprintf(&b, "<text x=\"16\" y=\"%g\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#111\">%s</text>\n",
top-12, esc(c.YLabel))
for i, s := range c.Series {
colour := colour(i)
fmt.Fprintf(&b, "<polyline fill=\"none\" stroke=\"%s\" stroke-width=\"1.8\" points=\"", colour)
for j, p := range s.Points {
if j > 0 {
b.WriteByte(' ')
}
fmt.Fprintf(&b, "%.2f,%.2f", px(p.X), py(p.Y))
}
b.WriteString("\"/>\n")
ly := top + 16 + float64(i)*16
fmt.Fprintf(&b, "<line x1=\"%g\" y1=\"%g\" x2=\"%g\" y2=\"%g\" stroke=\"%s\" stroke-width=\"1.8\"/>\n",
float64(w)-230, ly, float64(w)-214, ly, colour)
fmt.Fprintf(&b, "<text x=\"%g\" y=\"%g\" font-family=\"sans-serif\" font-size=\"11\" fill=\"#111\">%s</text>\n",
float64(w)-208, ly+4, esc(s.Name))
}
b.WriteString("</svg>\n")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return base.Errf("Plot: %w", err)
}
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
return base.Errf("Plot: %w", err)
}
return nil
}
func bounds(pts []Point, xAxis bool) [2]float64 {
lo, hi := math.Inf(1), math.Inf(-1)
for _, p := range pts {
v := p.Y
if xAxis {
v = p.X
}
lo = math.Min(lo, v)
hi = math.Max(hi, v)
}
if hi == lo {
hi = lo + 1
}
pad := 0.05 * (hi - lo)
return [2]float64{lo - pad, hi + pad}
}
func tick(v float64) string {
if v == math.Trunc(v) && math.Abs(v) < 1e15 {
return fmt.Sprintf("%d", int64(v))
}
return fmt.Sprintf("%g", v)
}
func esc(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&#34;")
return r.Replace(s)
}
// seriesColours is the chart's fixed colour cycle: seven even samples
// of the Viridis perceptual-uniform map (Nathaniel J. Smith, Stéfan
// van der Walt and Eric Firing, released under CC0), read from the
// map's 256-entry table at t = 0, 7/60, ..., 0.7 by linear
// interpolation, each channel rounded to the nearest byte. The map's
// light tail is left out on purpose: the
// chart paints on white, and the pale yellows the full range ends in
// drop far below a legible contrast at stroke width, while the
// sampled range runs dark violet through blue and teal to green with
// every stroke legible. The cycle is a constant, so the same chart
// renders the same colours byte for byte, like everything else it
// draws.
var seriesColours = [7]string{
"#440154",
"#482a79",
"#3d4d8a",
"#2f6c8e",
"#23888e",
"#20a486",
"#43bf71",
}
func colour(i int) string {
return seriesColours[i%len(seriesColours)]
}
const xmlHeader = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+184
View File
@@ -0,0 +1,184 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package plot
import (
"math"
"os"
"path/filepath"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
func sampleChart() Chart {
pts1 := make([]Point, 0, 50)
pts2 := make([]Point, 0, 50)
for i := range 50 {
x := float64(i) / 49
pts1 = append(pts1, Point{X: x, Y: x * x})
pts2 = append(pts2, Point{X: x, Y: math.Sqrt(x)})
}
return Chart{
Title: "Test <chart>",
XLabel: "x", YLabel: "y",
Series: []Series{{Name: "quadratic", Points: pts1}, {Name: "sqrt", Points: pts2}},
}
}
func TestWriteSVG(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "out.svg")
if err := sampleChart().WriteSVG(path); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
s := string(body)
if !strings.HasPrefix(s, "<?xml") {
t.Fatal("the SVG lacks the XML declaration")
}
if got := strings.Count(s, "<polyline"); got != 2 {
t.Fatalf("%d polylines, want 2", got)
}
if strings.Count(s, "&lt;") != 1 {
t.Fatal("the title was not escaped")
}
// Determinism: the same chart renders byte for byte the same file.
path2 := filepath.Join(dir, "out2.svg")
if err := sampleChart().WriteSVG(path2); err != nil {
t.Fatal(err)
}
again, err := os.ReadFile(path2)
if err != nil {
t.Fatal(err)
}
if string(body) != string(again) {
t.Fatal("the rendering is not deterministic")
}
}
func TestWriteSVGErrors(t *testing.T) {
dir := t.TempDir()
one := Chart{Series: []Series{{Name: "only", Points: []Point{{X: 0, Y: 0}, {X: 1, Y: 1}}}}}
one.Series[0].Points = one.Series[0].Points[:1]
if err := one.WriteSVG(filepath.Join(dir, "bad.svg")); err == nil {
t.Fatal("a single point must fail")
}
empty := Chart{Series: []Series{{Name: "none"}}}
if err := empty.WriteSVG(filepath.Join(dir, "bad.svg")); err == nil {
t.Fatal("an empty series must fail")
}
// A series built by hand is the caller's own: the constructor's
// non-finite refusal still has to hold at the rendering door, or
// the deterministic contract would publish a polyline carrying the
// literal NaN no renderer draws.
for _, pt := range []Point{{X: math.NaN(), Y: 0}, {X: 0, Y: math.Inf(1)}, {X: math.Inf(-1), Y: 1}} {
nan := Chart{Series: []Series{{Name: "broken", Points: []Point{{X: 0, Y: 0}, pt, {X: 2, Y: 1}}}}}
if err := nan.WriteSVG(filepath.Join(dir, "bad.svg")); err == nil {
t.Fatalf("the non-finite point %+v was rendered", pt)
}
}
// An axis range with an infinite or unordered span falls back to
// the data's own bounds instead of mapping every point through a
// division the format cannot evaluate.
for _, r := range [][2]float64{{0, math.Inf(1)}, {math.Inf(-1), 0}, {2, 1}, {math.NaN(), 1}} {
c := sampleChart()
c.YRange = r
path := filepath.Join(dir, "range.svg")
if err := c.WriteSVG(path); err != nil {
t.Fatalf("the range %v was refused instead of falling back: %v", r, err)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if s := string(body); strings.Contains(s, "NaN") || strings.Contains(s, "Inf") {
t.Fatalf("the range %v leaked a non-finite coordinate into the rendering", r)
}
}
}
func mustFromFloats(t *testing.T, values []float64, shape ...int) *core.Array {
t.Helper()
a, err := core.FromFloats(values, shape...)
if err != nil {
t.Fatal(err)
}
return a
}
func TestLine(t *testing.T) {
xs := mustFromFloats(t, []float64{0, 1, 2, 3}, 4)
ys := mustFromFloats(t, []float64{0, 1, 4, 9}, 4)
s, err := Line("quadratic", xs, ys)
if err != nil {
t.Fatal(err)
}
if s.Name != "quadratic" || len(s.Points) != 4 || s.Points[3] != (Point{X: 3, Y: 9}) {
t.Fatalf("the series is wrong: %+v", s)
}
}
func TestLineValidation(t *testing.T) {
xs := mustFromFloats(t, []float64{0, 1, 2}, 3)
ys := mustFromFloats(t, []float64{0, 1, 4}, 3)
if _, err := Line("mismatch", xs, mustFromFloats(t, []float64{0, 1}, 2)); err == nil {
t.Fatal("a length mismatch must fail")
}
if _, err := Line("matrix", mustFromFloats(t, []float64{1, 2, 3, 4}, 2, 2), ys); err == nil {
t.Fatal("a rank-2 array must fail")
}
if _, err := Line("empty", mustFromFloats(t, nil, 0), mustFromFloats(t, nil, 0)); err == nil {
t.Fatal("an empty array must fail")
}
if _, err := Line("nil", nil, ys); err == nil {
t.Fatal("a nil array must fail")
}
if _, err := Line("nan", xs, mustFromFloats(t, []float64{0, 1, math.NaN()}, 3)); err == nil {
t.Fatal("a non-finite point must fail")
}
}
func TestTick(t *testing.T) {
if tick(2) != "2" {
t.Fatalf("tick(2) = %q", tick(2))
}
if tick(0.25) != "0.25" {
t.Fatalf("tick(0.25) = %q", tick(0.25))
}
if tick(1e-7) != "1e-07" {
t.Fatalf("tick(1e-7) = %q", tick(1e-7))
}
}
// TestSeriesColourCycle pins the colour cycle element for element: the
// seven Viridis samples are part of the deterministic output, so an
// accidental edit must fall over here, and the cycle must wrap at the
// palette's own length.
func TestSeriesColourCycle(t *testing.T) {
want := [7]string{
"#440154",
"#482a79",
"#3d4d8a",
"#2f6c8e",
"#23888e",
"#20a486",
"#43bf71",
}
if seriesColours != want {
t.Fatalf("series colours = %v, want %v", seriesColours, want)
}
for i := range len(seriesColours) {
if colour(i) != seriesColours[i] {
t.Fatalf("colour(%d) = %s, want %s", i, colour(i), seriesColours[i])
}
if colour(i+len(seriesColours)) != seriesColours[i] {
t.Fatalf("colour(%d) does not wrap onto colour(%d)", i+len(seriesColours), i)
}
}
}