Files
tensor/io/hdf5write_test.go
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

1277 lines
46 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package io
import (
"bytes"
"encoding/binary"
"fmt"
"math"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// The writer tests round-trip through the reader: every file the
// writer produces is read back with LoadHDF5, the verified decoders
// of hdf5.go, and compared element for element, integers exactly and
// floats bit for bit. The reference fixtures under testdata/h5 close
// the circle: read, written, read again.
// hdf5WriteRead writes one file and reads it back, the shape every
// test below takes.
func hdf5WriteRead(t *testing.T, sets []HDF5Dataset, groupAttrs map[string]map[string]string, opts HDF5WriteOptions) []HDF5Dataset {
t.Helper()
path := filepath.Join(t.TempDir(), "written.h5")
if err := SaveHDF5(path, sets, groupAttrs, opts); err != nil {
t.Fatalf("SaveHDF5: %v", err)
}
back, err := LoadHDF5(path)
if err != nil {
t.Fatalf("LoadHDF5: %v", err)
}
return back
}
// hdf5CompareSets compares two dataset listings by path: shapes, dtypes,
// values and attributes, integers exactly and floats bit for bit. Both
// listings are compared in the reader's path order.
func hdf5CompareSets(t *testing.T, want, got []HDF5Dataset) {
t.Helper()
byPath := func(a, b HDF5Dataset) int { return strings.Compare(a.Path, b.Path) }
slices.SortFunc(want, byPath)
slices.SortFunc(got, byPath)
if len(want) != len(got) {
t.Fatalf("datasets = %d, want %d", len(got), len(want))
}
for i := range want {
w, g := want[i], got[i]
if g.Path != w.Path {
t.Fatalf("dataset %d path = %q, want %q", i, g.Path, w.Path)
}
if !slices.Equal(g.Shape, w.Shape) {
t.Fatalf("%s shape = %v, want %v", w.Path, g.Shape, w.Shape)
}
if g.Values.Dtype() != w.Values.Dtype() {
t.Fatalf("%s dtype = %s, want %s", w.Path, g.Values.Dtype(), w.Values.Dtype())
}
switch w.Values.Dtype() {
case core.Int:
gi, wi := g.Values.RawInts(), w.Values.RawInts()
for j := range wi {
if gi[j] != wi[j] {
t.Fatalf("%s[%d] = %d, want %d", w.Path, j, gi[j], wi[j])
}
}
case core.Float:
gf, wf := g.Values.RawFloats(), w.Values.RawFloats()
for j := range wf {
if math.Float64bits(gf[j]) != math.Float64bits(wf[j]) {
t.Fatalf("%s[%d] = %v, want %v (bit-exact)", w.Path, j, gf[j], wf[j])
}
}
case core.Float32:
gf, wf := g.Values.RawFloat32s(), w.Values.RawFloat32s()
for j := range wf {
if math.Float32bits(gf[j]) != math.Float32bits(wf[j]) {
t.Fatalf("%s[%d] = %v, want %v (bit-exact)", w.Path, j, gf[j], wf[j])
}
}
case core.Bool:
gb, wb := g.Values.RawBools(), w.Values.RawBools()
for j := range wb {
if gb[j] != wb[j] {
t.Fatalf("%s[%d] = %v, want %v", w.Path, j, gb[j], wb[j])
}
}
case core.Int8:
gi, wi := g.Values.RawInt8s(), w.Values.RawInt8s()
for j := range wi {
if gi[j] != wi[j] {
t.Fatalf("%s[%d] = %d, want %d", w.Path, j, gi[j], wi[j])
}
}
case core.Uint8:
gi, wi := g.Values.RawUint8s(), w.Values.RawUint8s()
for j := range wi {
if gi[j] != wi[j] {
t.Fatalf("%s[%d] = %d, want %d", w.Path, j, gi[j], wi[j])
}
}
case core.Int16:
gi, wi := g.Values.RawInt16s(), w.Values.RawInt16s()
for j := range wi {
if gi[j] != wi[j] {
t.Fatalf("%s[%d] = %d, want %d", w.Path, j, gi[j], wi[j])
}
}
case core.Uint16:
gi, wi := g.Values.RawUint16s(), w.Values.RawUint16s()
for j := range wi {
if gi[j] != wi[j] {
t.Fatalf("%s[%d] = %d, want %d", w.Path, j, gi[j], wi[j])
}
}
case core.Int32:
gi, wi := g.Values.RawInt32s(), w.Values.RawInt32s()
for j := range wi {
if gi[j] != wi[j] {
t.Fatalf("%s[%d] = %d, want %d", w.Path, j, gi[j], wi[j])
}
}
case core.Uint32:
gi, wi := g.Values.RawUint32s(), w.Values.RawUint32s()
for j := range wi {
if gi[j] != wi[j] {
t.Fatalf("%s[%d] = %d, want %d", w.Path, j, gi[j], wi[j])
}
}
default:
t.Fatalf("%s: the comparison pins no values for dtype %s", w.Path, w.Values.Dtype())
}
if len(g.Attrs) != len(w.Attrs) {
t.Fatalf("%s attrs = %v, want %v", w.Path, g.Attrs, w.Attrs)
}
for k, v := range w.Attrs {
if g.Attrs[k] != v {
t.Fatalf("%s attr %q = %q, want %q", w.Path, k, g.Attrs[k], v)
}
}
}
}
// TestHDF5WriteRoundTrip writes every numeric dtype in every rank the
// model carries, contiguous, and reads the values back exactly.
func TestHDF5WriteRoundTrip(t *testing.T) {
floats, err := core.FromFloats([]float64{1.5, -2.5, math.Pi, math.MaxFloat64, math.SmallestNonzeroFloat64, 0, math.Inf(-1)}, 7)
if err != nil {
t.Fatal(err)
}
matrix, err := core.FromFloats([]float64{1, 2, 3, 4, 5, 6}, 2, 3)
if err != nil {
t.Fatal(err)
}
cube, err := core.FromFloats([]float64{1, 2, 3, 4, 5, 6, 7, 8}, 2, 2, 2)
if err != nil {
t.Fatal(err)
}
singles, err := core.FromFloat32s([]float32{1, -1.5, 3.25, 1e-20, math.MaxFloat32}, 5)
if err != nil {
t.Fatal(err)
}
ints, err := core.FromInts([]int64{0, 1, -1, math.MaxInt64, math.MinInt64 + 1, 1 << 40}, 2, 3)
if err != nil {
t.Fatal(err)
}
empty, err := core.FromInts([]int64{}, 0, 3)
if err != nil {
t.Fatal(err)
}
want := []HDF5Dataset{
{Path: "/empty", Shape: empty.Shape(), Values: empty},
{Path: "/floats", Shape: floats.Shape(), Values: floats},
{Path: "/ints", Shape: ints.Shape(), Values: ints},
{Path: "/matrix", Shape: matrix.Shape(), Values: matrix},
{Path: "/singles", Shape: singles.Shape(), Values: singles},
{Path: "/cube", Shape: cube.Shape(), Values: cube},
}
got := hdf5WriteRead(t, want, nil, HDF5WriteOptions{})
hdf5CompareSets(t, want, got)
// The same again through the latest layout, which stores every
// dataset contiguously with checksummed headers.
got = hdf5WriteRead(t, want, nil, HDF5WriteOptions{Latest: true})
hdf5CompareSets(t, want, got)
}
// TestHDF5WriteGroupsAndAttrs writes nested groups with attributes on
// the root, the groups and the datasets, in both layouts, and checks
// what the reader reports: the dataset's own attributes and the ones
// inherited from the groups above it.
func TestHDF5WriteGroupsAndAttrs(t *testing.T) {
for _, latest := range []bool{false, true} {
inner, err := core.FromFloats([]float64{1, 2}, 2)
if err != nil {
t.Fatal(err)
}
outer, err := core.FromInts([]int64{7}, 1)
if err != nil {
t.Fatal(err)
}
sets := []HDF5Dataset{
{Path: "/a/b/inner", Shape: inner.Shape(), Values: inner, Attrs: map[string]string{"units": "m/s"}},
{Path: "/outer", Shape: outer.Shape(), Values: outer, Attrs: map[string]string{"note": "top level"}},
}
groupAttrs := map[string]map[string]string{
"/": {"title": "grouped"},
"/a": {"units": "group units"},
"/a/b": {"depth": "2"},
}
got := hdf5WriteRead(t, sets, groupAttrs, HDF5WriteOptions{Latest: latest})
byPath := map[string]HDF5Dataset{}
for _, d := range got {
byPath[d.Path] = d
}
innerGot, ok := byPath["/a/b/inner"]
if !ok {
t.Fatalf("latest=%v: /a/b/inner is missing in %v", latest, hdf5Paths(got))
}
// The dataset's own attribute and everything inherited from the
// groups above it, the nearest group winning.
for k, want := range map[string]string{"units": "m/s", "title": "grouped", "depth": "2"} {
if got := innerGot.Attrs[k]; got != want {
t.Fatalf("latest=%v: /a/b/inner attr %q = %q, want %q", latest, k, got, want)
}
}
outerGot := byPath["/outer"]
for k, want := range map[string]string{"note": "top level", "title": "grouped"} {
if got := outerGot.Attrs[k]; got != want {
t.Fatalf("latest=%v: /outer attr %q = %q, want %q", latest, k, got, want)
}
}
}
}
// hdf5Paths lists the paths of a dataset listing, for error messages.
func hdf5Paths(sets []HDF5Dataset) []string {
out := make([]string, len(sets))
for i, d := range sets {
out[i] = d.Path
}
return out
}
// TestHDF5WriteChunked writes chunked datasets through the deflate
// filter, the only route this writer takes to chunked storage: whole
// dataset chunks, edge chunks hanging past the shape, and enough
// chunks to force a multi-level chunk B-tree.
func TestHDF5WriteChunked(t *testing.T) {
// ChunkBytes 64 splits the float64 axis into eight-element chunks;
// 598 elements make 75 chunks, which is past the sixty-four
// children one chunk B-tree node holds, and the last chunk hangs
// six elements past the shape.
want := make([]float64, 598)
for i := range want {
want[i] = float64(i) * 1.5
}
values, err := core.FromFloats(want, 598)
if err != nil {
t.Fatal(err)
}
// A three-dimensional dataset whose extent is not a whole number of
// chunks along the trailing axis: the edge chunks are stored padded.
rows := make([]float64, 0, 5*3*7)
for i := range 5 * 3 * 7 {
rows = append(rows, float64(i))
}
matrix, err := core.FromFloats(rows, 5, 3, 7)
if err != nil {
t.Fatal(err)
}
sets := []HDF5Dataset{
{Path: "/long", Shape: values.Shape(), Values: values},
{Path: "/box", Shape: matrix.Shape(), Values: matrix},
}
got := hdf5WriteRead(t, sets, nil, HDF5WriteOptions{Gzip: 1, ChunkBytes: 64})
hdf5CompareSets(t, sets, got)
}
// TestHDF5WriteFilters round-trips the deflate and shuffle filters at
// several levels, alone and together, on data whose chunking crosses
// chunk B-tree leaves.
func TestHDF5WriteFilters(t *testing.T) {
want := make([]float64, 1000)
for i := range want {
// Runs of equal values compress; the tail differs so the
// round-trip is not trivially constant.
if i < 900 {
want[i] = float64(i / 10)
} else {
want[i] = math.Sin(float64(i))
}
}
values, err := core.FromFloats(want, 1000)
if err != nil {
t.Fatal(err)
}
ints, err := core.FromInts([]int64{5, 3, 5, 3, 5, 3, 5, 3}, 8)
if err != nil {
t.Fatal(err)
}
sets := []HDF5Dataset{
{Path: "/gzip_shuffle", Shape: values.Shape(), Values: values},
{Path: "/ints", Shape: ints.Shape(), Values: ints},
}
for _, opts := range []HDF5WriteOptions{
{Gzip: 1, ChunkBytes: 128},
{Gzip: 6, ChunkBytes: 128},
{Gzip: 9, ChunkBytes: 128},
{Gzip: -1, ChunkBytes: 128},
{Shuffle: true, ChunkBytes: 128},
{Shuffle: true, Gzip: 4, ChunkBytes: 64},
{Shuffle: true, Gzip: 6},
} {
got := hdf5WriteRead(t, sets, nil, opts)
hdf5CompareSets(t, sets, got)
}
// The deflate stage genuinely compresses: eight hundred bytes of
// repeating values deflate to a fraction of themselves. (The file
// as a whole carries the B-tree nodes the format allocates, which
// dwarf the data of a test-sized dataset.)
flat := bytes.Repeat([]byte{0, 0, 0, 0, 0, 0, 0xF0, 0x3F}, 100)
small, err := (&hdf5Writer{}).deflateChunk(flat, 6)
if err != nil {
t.Fatal(err)
}
if len(small) >= len(flat) {
t.Fatalf("the deflate filter stored %d bytes for %d of data", len(small), len(flat))
}
}
// TestHDF5WriteWideGroup writes a group with more children than two
// symbol table nodes hold, which forces a multi-level group B-tree,
// and reads every child back by path.
func TestHDF5WriteWideGroup(t *testing.T) {
// 260 children: 33 symbol table nodes, which is past the
// thirty-two children one group B-tree node holds, so the tree
// grows a second level.
var want []HDF5Dataset
for i := range 260 {
v, err := core.FromInts([]int64{int64(i)}, 1)
if err != nil {
t.Fatal(err)
}
want = append(want, HDF5Dataset{Path: fmt.Sprintf("/g/d%03d", i), Shape: v.Shape(), Values: v})
}
for _, latest := range []bool{false, true} {
got := hdf5WriteRead(t, want, nil, HDF5WriteOptions{Latest: latest})
hdf5CompareSets(t, want, got)
}
}
// TestHDF5WriteAttrs pins the attribute round-trip: whole numbers
// return as int64 attributes, decimals as float64 ones, bracketed
// lists as arrays, and any other text as a fixed-length string.
func TestHDF5WriteAttrs(t *testing.T) {
values, err := core.FromInts([]int64{1}, 1)
if err != nil {
t.Fatal(err)
}
sets := []HDF5Dataset{
{Path: "/x", Shape: values.Shape(), Values: values, Attrs: map[string]string{
"n": "7",
"neg": "-12",
"x": "2.5",
"tiny": "1e-300",
"arr": "[1, 2, 3]",
"floats": "[0.5, -1.25, 2.75]",
"label": "hello world",
"short": "K",
"unicode": "vlno moe",
"spacey": " padded ",
}},
}
got := hdf5WriteRead(t, sets, nil, HDF5WriteOptions{})
for k, want := range sets[0].Attrs {
if got[0].Attrs[k] != want {
t.Fatalf("attr %q = %q, want %q", k, got[0].Attrs[k], want)
}
}
// A float64 attribute of a whole value reads back as "3" and is
// rewritten as the int64 attribute "3": the value is preserved,
// the type is the writer's inference.
floaty, err := core.FromFloats([]float64{1}, 1)
if err != nil {
t.Fatal(err)
}
sets = []HDF5Dataset{{Path: "/x", Shape: floaty.Shape(), Values: floaty, Attrs: map[string]string{"v": "3"}}}
got = hdf5WriteRead(t, sets, nil, HDF5WriteOptions{})
if got[0].Attrs["v"] != "3" {
t.Fatalf("attr v = %q, want %q", got[0].Attrs["v"], "3")
}
// Non-numeric array elements are refused by name.
sets[0].Attrs = map[string]string{"bad": "[1, x]"}
path := filepath.Join(t.TempDir(), "bad.h5")
if err := SaveHDF5(path, sets, nil); err == nil {
t.Fatal("expected an error for a non-numeric array attribute")
} else if !strings.Contains(err.Error(), "not a number") {
t.Fatalf("error = %v, want a not-a-number refusal", err)
}
}
// TestHDF5WriteText writes fixed-length string datasets, checks the
// datatype message they produce against the reader's own decoder, and
// confirms the reader refuses them exactly as its documentation says.
func TestHDF5WriteText(t *testing.T) {
path := filepath.Join(t.TempDir(), "text.h5")
texts := []HDF5TextDataset{
{Path: "/words", Shape: []int{4}, Text: []string{"forty two", "hi", "", "K"}},
{Path: "/grid", Shape: []int{2, 2}, Text: []string{"a", "bb", "ccc", "dddd"}},
}
if err := SaveHDF5Text(path, texts, HDF5WriteOptions{}); err != nil {
t.Fatalf("SaveHDF5Text: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
// The words dataset stores four elements of nine bytes: the
// datatype message is the fixed-length string class the reader
// decodes for attributes, and its width is the longest element.
pattern := []byte{0x13, 0x01, 0, 0}
pattern = binary.LittleEndian.AppendUint32(pattern, 9)
at := bytes.Index(raw, pattern)
if at < 0 {
t.Fatalf("no nine-byte fixed-string datatype message in %d bytes", len(raw))
}
dtype, err := decodeType(raw[at:at+8], true)
if err != nil {
t.Fatalf("decodeType: %v", err)
}
if dtype.class != 3 || dtype.width != 9 {
t.Fatalf("datatype = class %d width %d, want class 3 width 9", dtype.class, dtype.width)
}
// The reader refuses string datasets by name.
if _, err := LoadHDF5(path); err == nil {
t.Fatal("expected the reader to refuse a string dataset")
} else if !strings.Contains(err.Error(), "string datasets are not supported") {
t.Fatalf("error = %v, want the string dataset refusal", err)
}
// The latest layout carries them too, still refused by the reader.
path = filepath.Join(t.TempDir(), "text3.h5")
if err := SaveHDF5Text(path, texts[:1], HDF5WriteOptions{Latest: true}); err != nil {
t.Fatalf("SaveHDF5Text: %v", err)
}
if _, err := LoadHDF5(path); err == nil {
t.Fatal("expected the reader to refuse a latest-format string dataset")
}
// Filters are refused by name rather than silently dropped.
if err := SaveHDF5Text(filepath.Join(t.TempDir(), "z.h5"), texts, HDF5WriteOptions{Gzip: 6}); err == nil {
t.Fatal("expected an error for filters on string datasets")
}
// A element count that does not fill the shape is refused.
if err := SaveHDF5Text(filepath.Join(t.TempDir(), "s.h5"), []HDF5TextDataset{{Path: "/s", Shape: []int{3}, Text: []string{"a", "b"}}}, HDF5WriteOptions{}); err == nil {
t.Fatal("expected an error for a short text list")
}
}
// TestHDF5WriteLatest pins the latest layout against its checksums:
// the file reads back, and a single flipped byte inside either
// checksummed region, the superblock or an object header, refuses the
// read. The classic layout carries no such checksums, and a flipped
// data byte there reads back corrupted but without error.
func TestHDF5WriteLatest(t *testing.T) {
values, err := core.FromFloats([]float64{1, 2, 3}, 3)
if err != nil {
t.Fatal(err)
}
deep, err := core.FromInts([]int64{9, 8}, 2)
if err != nil {
t.Fatal(err)
}
sets := []HDF5Dataset{
// The root attribute reaches every dataset on read, the
// reader's inheritance rule, so the expected listings carry it.
{Path: "/g/d", Shape: values.Shape(), Values: values, Attrs: map[string]string{"title": "latest"}},
{Path: "/g/h/e", Shape: deep.Shape(), Values: deep, Attrs: map[string]string{"title": "latest"}},
}
path := filepath.Join(t.TempDir(), "latest.h5")
if err := SaveHDF5(path, sets, map[string]map[string]string{"/": {"title": "latest"}}, HDF5WriteOptions{Latest: true}); err != nil {
t.Fatal(err)
}
got, err := LoadHDF5(path)
if err != nil {
t.Fatalf("LoadHDF5: %v", err)
}
hdf5CompareSets(t, sets, got)
// The superblock carries a lookup3 checksum over its first forty
// bytes; byte 11 is the consistency flags, which nothing else
// reads. The root object header, found the way the reader finds
// it, checksums signature to last message, and the writer lays the
// datasets' data out in front of it, so a flip in the first bytes
// past the superblock lands in unchecksummed data instead.
whole, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if got := hdf5Lookup3(whole[:44]); got != binary.LittleEndian.Uint32(whole[44:]) {
t.Fatalf("the written superblock fails its own checksum: %#08x", got)
}
f, err := newHDF5File(whole)
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
name string
at int
}{
{"superblock", 11},
{"object header", int(f.rootAddress) + 8},
} {
corrupt := slices.Clone(whole)
corrupt[c.at] ^= 0xff
bad := filepath.Join(t.TempDir(), "corrupt.h5")
if err := os.WriteFile(bad, corrupt, 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadHDF5(bad); err == nil {
t.Fatalf("expected an error for a corrupted %s", c.name)
} else if !strings.Contains(err.Error(), "checksum") {
t.Fatalf("corrupted %s: error = %v, want a checksum refusal", c.name, err)
}
}
// The classic layout carries no checksums: a flipped data byte
// reads back changed, silently.
classic := filepath.Join(t.TempDir(), "classic.h5")
if err := SaveHDF5(classic, sets[:1], nil); err != nil {
t.Fatal(err)
}
whole, err = os.ReadFile(classic)
if err != nil {
t.Fatal(err)
}
// The dataset holds 1, 2, 3: the little-endian bytes of the value 1
// sit in the data region and nowhere else in a file this small.
one := binary.LittleEndian.AppendUint64(nil, math.Float64bits(1))
at := bytes.Index(whole, one)
if at < 0 {
t.Fatal("the value 1 is not in the file")
}
whole[at] ^= 0xff
flipped := filepath.Join(t.TempDir(), "flipped.h5")
if err := os.WriteFile(flipped, whole, 0o644); err != nil {
t.Fatal(err)
}
back, err := LoadHDF5(flipped)
if err != nil {
t.Fatalf("LoadHDF5: %v", err)
}
if back[0].Values.RawFloats()[0] == 1 {
t.Fatal("the flipped byte did not reach the values")
}
}
// TestHDF5WriteFixtureRoundTrip reads each reference fixture, writes
// it back and reads again: paths, shapes, dtypes, values and
// attributes must agree exactly, in the dtypes the fixtures store
// natively. fletcher.h5 is stored chunked and compressed; the
// round-trip rewrites it contiguous, so only the values and attributes
// are the contract there. fixture.h5's int32 dataset is written as
// int32, which the writer stores at its own width and the reader lands
// int32 again.
func TestHDF5WriteFixtureRoundTrip(t *testing.T) {
for _, c := range []struct {
name string
latest bool
}{
{"fixture.h5", false},
{"fletcher.h5", false},
{"latest.h5", true},
} {
sets, err := LoadHDF5(h5Fixture(t, c.name))
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
got := hdf5WriteRead(t, sets, nil, HDF5WriteOptions{Latest: c.latest})
hdf5CompareSets(t, sets, got)
}
}
// TestHDF5WriteRefusals pins the errors: every malformed input is
// refused with a message that names the defect, before any bytes are
// written.
func TestHDF5WriteRefusals(t *testing.T) {
dir := t.TempDir()
values, err := core.FromInts([]int64{1}, 1)
if err != nil {
t.Fatal(err)
}
good := HDF5Dataset{Path: "/x", Shape: values.Shape(), Values: values}
bad := func(sets []HDF5Dataset, attrs map[string]map[string]string, opts HDF5WriteOptions) error {
return SaveHDF5(filepath.Join(dir, "bad.h5"), sets, attrs, opts)
}
for _, c := range []struct {
name string
err error
text string
}{
{"relative path", bad([]HDF5Dataset{{Path: "x", Shape: values.Shape(), Values: values}}, nil, HDF5WriteOptions{}), "absolute"},
{"root path", bad([]HDF5Dataset{{Path: "/", Shape: values.Shape(), Values: values}}, nil, HDF5WriteOptions{}), "root"},
{"empty segment", bad([]HDF5Dataset{{Path: "/a//b", Shape: values.Shape(), Values: values}}, nil, HDF5WriteOptions{}), "empty segment"},
{"trailing slash", bad([]HDF5Dataset{{Path: "/a/", Shape: values.Shape(), Values: values}}, nil, HDF5WriteOptions{}), "empty segment"},
{"duplicate", bad([]HDF5Dataset{good, good}, nil, HDF5WriteOptions{}), "twice"},
{"group conflict", bad([]HDF5Dataset{good, {Path: "/x/y", Shape: values.Shape(), Values: values}}, nil, HDF5WriteOptions{}), "both a dataset and a group"},
{"no values", bad([]HDF5Dataset{{Path: "/x"}}, nil, HDF5WriteOptions{}), "no values"},
{"unknown attribute group", bad([]HDF5Dataset{good}, map[string]map[string]string{"/nope": {"a": "1"}}, HDF5WriteOptions{}), "does not name a group"},
{"empty attribute name", bad([]HDF5Dataset{good}, map[string]map[string]string{"/": {"": "1"}}, HDF5WriteOptions{}), "empty name"},
{"nul in path", bad([]HDF5Dataset{{Path: "/a\x00b", Shape: values.Shape(), Values: values}}, nil, HDF5WriteOptions{}), "NUL"},
{"nul in attribute", bad([]HDF5Dataset{good}, map[string]map[string]string{"/": {"a\x00": "1"}}, HDF5WriteOptions{}), "NUL"},
{"latest with gzip", bad([]HDF5Dataset{good}, nil, HDF5WriteOptions{Latest: true, Gzip: 6}), "version 2 B-tree"},
{"latest with shuffle", bad([]HDF5Dataset{good}, nil, HDF5WriteOptions{Latest: true, Shuffle: true}), "version 2 B-tree"},
{"gzip level", bad([]HDF5Dataset{good}, nil, HDF5WriteOptions{Gzip: 10}), "gzip level"},
{"negative chunk target", bad([]HDF5Dataset{good}, nil, HDF5WriteOptions{ChunkBytes: -1}), "negative"},
{"disagreeing shape", bad([]HDF5Dataset{{Path: "/x", Shape: []int{2, 1}, Values: values}}, nil, HDF5WriteOptions{}), "declares a shape"},
} {
if c.err == nil {
t.Errorf("%s: expected an error, got none", c.name)
continue
}
if !strings.Contains(c.err.Error(), c.text) {
t.Errorf("%s: error = %v, want it to name %q", c.name, c.err, c.text)
}
}
// A complex dataset is refused: the format carries it, the core
// does not.
complexes, err := core.FromComplexes([]complex128{1}, 1)
if err != nil {
t.Fatal(err)
}
err = bad([]HDF5Dataset{{Path: "/z", Shape: complexes.Shape(), Values: complexes}}, nil, HDF5WriteOptions{})
if err == nil || !strings.Contains(err.Error(), "not supported") {
t.Fatalf("complex dtype: error = %v, want an unsupported-dtype refusal", err)
}
// A chunk target so small that the chunk count explodes is refused
// with the lever named; the shape is laid out directly because
// materialising that many elements would cost more than the
// refusal is worth.
chunky := &hdf5Writer{opts: HDF5WriteOptions{ChunkBytes: 8}}
_, _, cerr := chunky.writeChunks(&hdf5OutSet{path: "/w", shape: []int{hdf5MaxChunks + 1}, width: 8})
if cerr == nil || !strings.Contains(cerr.Error(), "chunks") {
t.Fatalf("tiny chunks: error = %v, want a chunk-count refusal", cerr)
}
// A text shape whose extents wrap the element count onto zero
// matches an empty Text slice exactly, so the length check alone
// cannot refuse it: the byte budget must, before a header goes out
// declaring 2^64 elements.
terr := SaveHDF5Text(filepath.Join(dir, "bad-text.h5"),
[]HDF5TextDataset{{Path: "/t", Shape: []int{4294967296, 4294967296}, Text: nil}})
if terr == nil || !strings.Contains(terr.Error(), "budget") {
t.Fatalf("wrapped text extents: error = %v, want the byte-budget refusal", terr)
}
// Nothing above may have left a valid file behind: the refusals
// happen before a byte is written.
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
t.Errorf("a refused write left %q behind", e.Name())
}
}
// TestHDF5WriteEmptyFilteredRoundTrip keeps a dataset with no elements
// out of the chunked path: the filters have nothing to compress and a
// zero extent divides the chunk grid into zero-sized chunks, so the
// dataset is stored contiguous at an undefined address in both
// layouts.
func TestHDF5WriteEmptyFilteredRoundTrip(t *testing.T) {
empty, err := core.FromFloats(nil, 0, 3)
if err != nil {
t.Fatal(err)
}
sets := []HDF5Dataset{{Path: "/empty", Shape: empty.Shape(), Values: empty}}
got := hdf5WriteRead(t, sets, nil, HDF5WriteOptions{Gzip: 6, Shuffle: true})
hdf5CompareSets(t, sets, got)
// The latest layout stores the empty dataset contiguous too; the
// filtered latest combination is refused by design and pinned in
// the refusals test.
got = hdf5WriteRead(t, sets, nil, HDF5WriteOptions{Latest: true})
hdf5CompareSets(t, sets, got)
}
// TestHDF5WriteLatestGroupBadAttrRefused pins the error the latest
// layout returns for a group attribute whose text does not parse: the
// refusal must come out of the write, not be overwritten by the
// header encode that follows it.
func TestHDF5WriteLatestGroupBadAttrRefused(t *testing.T) {
values, err := core.FromFloats([]float64{1}, 1)
if err != nil {
t.Fatal(err)
}
sets := []HDF5Dataset{{Path: "/leaf", Shape: values.Shape(), Values: values}}
attrs := map[string]map[string]string{"/": {"bad": "[1, x]"}}
path := filepath.Join(t.TempDir(), "bad.h5")
err = SaveHDF5(path, sets, attrs, HDF5WriteOptions{Latest: true})
if err == nil {
t.Fatal("SaveHDF5 with a malformed group attribute answered nil")
}
if !strings.Contains(err.Error(), "bad") {
t.Fatalf("error = %v, want it to name the attribute", err)
}
}
// TestHDF5WriteInterleavedNamesRoundTrip pins the child order the
// classic symbol table requires: the heap offsets and the B-tree keys
// sort in one merged name order, so a subgroup whose name interleaves
// with the datasets' is reachable. Round-trips both layouts.
func TestHDF5WriteInterleavedNamesRoundTrip(t *testing.T) {
values, err := core.FromFloats([]float64{1, 2, 3}, 3)
if err != nil {
t.Fatal(err)
}
sets := []HDF5Dataset{
{Path: "/alpha", Shape: values.Shape(), Values: values},
{Path: "/box", Shape: values.Shape(), Values: values},
{Path: "/deep/nested", Shape: values.Shape(), Values: values},
{Path: "/f32", Shape: values.Shape(), Values: values},
{Path: "/zoom/in", Shape: values.Shape(), Values: values},
}
for _, latest := range []bool{false, true} {
got := hdf5WriteRead(t, sets, nil, HDF5WriteOptions{Latest: latest})
hdf5CompareSets(t, sets, got)
}
}
// TestHDF5WriteChunkTreeKeyConvention parses a written chunk B-tree
// node and pins the key convention the reference files store: every
// real chunk key carries 0 in the trailing element-size coordinate and
// only the node's closing sentinel carries the element size, which is
// what orders it past the node's chunks. Coordinates ascend through
// the node.
func TestHDF5WriteChunkTreeKeyConvention(t *testing.T) {
want := make([]float64, 90)
for i := range want {
want[i] = float64(i)
}
values, err := core.FromFloats(want, 10, 9)
if err != nil {
t.Fatal(err)
}
w := &hdf5Writer{opts: HDF5WriteOptions{ChunkBytes: 4 * 8}}
s, err := hdf5PlanSet("SaveHDF5", &HDF5Dataset{Path: "/grid", Shape: values.Shape(), Values: values})
if err != nil {
t.Fatal(err)
}
tree, chunk, err := w.writeChunks(s)
if err != nil {
t.Fatal(err)
}
// One node: a 3x3 chunk grid sits far under the fanout.
node := w.buf[tree:]
if string(node[:4]) != "TREE" {
t.Fatalf("chunk tree signature = %q, want TREE", node[:4])
}
if node[5] != 0 {
t.Fatalf("chunk tree level = %d, want a leaf", node[5])
}
entries := int(binary.LittleEndian.Uint16(node[6:]))
if entries != 9 {
t.Fatalf("chunk tree entries = %d, want 9 (a 3x3 grid of %v)", entries, chunk)
}
keySize := 8 + 8*(len(s.shape)+1)
p := 24
for i := 0; i <= entries; i++ {
coords := make([]uint64, len(s.shape)+1)
for j := range coords {
coords[j] = binary.LittleEndian.Uint64(node[p+8+8*j:])
}
last := i == entries
if want := uint64(s.width); last {
if coords[len(coords)-1] != want {
t.Fatalf("sentinel trailing coordinate = %d, want the element size %d", coords[len(coords)-1], want)
}
} else {
if coords[len(coords)-1] != 0 {
t.Fatalf("chunk %d trailing coordinate = %d, want 0", i, coords[len(coords)-1])
}
if i > 0 && coords[len(coords)-2] <= binary.LittleEndian.Uint64(node[p-keySize+8+8*(len(coords)-2):]) && coords[0] == binary.LittleEndian.Uint64(node[p-keySize+8:]) {
t.Fatalf("chunk %d coordinates do not advance past chunk %d", i, i-1)
}
}
p += keySize
if !last {
p += 8
}
}
}
// TestHDF5WriteDeterministicBytes pins the writer's determinism claim:
// the same content supplied in different dataset and attribute orders
// writes byte for bit the same file.
func TestHDF5WriteDeterministicBytes(t *testing.T) {
a, err := core.FromFloats([]float64{1, 2, 3}, 3)
if err != nil {
t.Fatal(err)
}
b, err := core.FromInts([]int64{7, 8}, 2)
if err != nil {
t.Fatal(err)
}
files := make([][]byte, 2)
for i, sets := range [][]HDF5Dataset{
{{Path: "/a", Shape: a.Shape(), Values: a}, {Path: "/b", Shape: b.Shape(), Values: b}},
{{Path: "/b", Shape: b.Shape(), Values: b}, {Path: "/a", Shape: a.Shape(), Values: a}},
} {
path := filepath.Join(t.TempDir(), "det.h5")
if err := SaveHDF5(path, sets, map[string]map[string]string{"/": {"x": "1", "y": "2"}}, HDF5WriteOptions{}); err != nil {
t.Fatal(err)
}
files[i], err = os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
}
if !bytes.Equal(files[0], files[1]) {
t.Fatalf("two writes of the same content in different orders differ in %d of %d bytes", countByteDiffs(files[0], files[1]), len(files[0]))
}
}
func countByteDiffs(a, b []byte) int {
n := 0
for i := range a {
if a[i] != b[i] {
n++
}
}
return n
}
// TestHDF5WriteChildOrderBytes pins the merged child order at the byte
// level, the pin the round-trip test cannot be: the reader walks the
// symbol nodes linearly and stays blind to their order, while the
// reference library binary-searches both the symbol node records and
// the B-tree keys on the local heap offsets. Ten root children whose
// names interleave groups and datasets span two symbol node leaves:
// their entries must read in one ascending heap-offset order matching
// the alphabetical names, and the group B-tree must route to the two
// leaves in order with closing keys on each leaf's last offset.
func TestHDF5WriteChildOrderBytes(t *testing.T) {
values, err := core.FromFloats([]float64{1}, 1)
if err != nil {
t.Fatal(err)
}
sets := []HDF5Dataset{
{Path: "/ann", Shape: values.Shape(), Values: values},
{Path: "/bee/x", Shape: values.Shape(), Values: values},
{Path: "/cee", Shape: values.Shape(), Values: values},
{Path: "/dee/x", Shape: values.Shape(), Values: values},
{Path: "/eff", Shape: values.Shape(), Values: values},
{Path: "/gee/x", Shape: values.Shape(), Values: values},
{Path: "/its/x", Shape: values.Shape(), Values: values},
{Path: "/jay", Shape: values.Shape(), Values: values},
{Path: "/mill", Shape: values.Shape(), Values: values},
{Path: "/zoo/x", Shape: values.Shape(), Values: values},
}
path := filepath.Join(t.TempDir(), "order.h5")
if err := SaveHDF5(path, sets, nil, HDF5WriteOptions{}); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
// The root group's leaves: every subgroup carries a symbol node of
// exactly one child, so the leaves of the root are the nodes with
// more. Eight children fit one leaf of eight slots, so ten spill
// into two, eight and two.
var leaves []int
for at := range hdf5ScanSignature(raw, []byte("SNOD")) {
if int(binary.LittleEndian.Uint16(raw[at+6:])) > 1 {
leaves = append(leaves, at)
}
}
if len(leaves) != 2 {
t.Fatalf("the root group spans %d symbol node leaves, want 2", len(leaves))
}
// The local heap's data segment: the header names its address.
heap := raw[hdf5FirstSignature(raw, []byte("HEAP")):]
dataAddr := binary.LittleEndian.Uint64(heap[24:])
nameAt := func(off uint64) string {
start := int(dataAddr + off)
end := start
for raw[end] != 0 {
end++
}
return string(raw[start:end])
}
var offsets []uint64
var names []string
for _, at := range leaves {
entries := int(binary.LittleEndian.Uint16(raw[at+6:]))
for i := range entries {
off := binary.LittleEndian.Uint64(raw[at+8+40*i:])
if len(offsets) > 0 && off <= offsets[len(offsets)-1] {
t.Fatalf("heap offsets not ascending across the leaves: %d then %d", offsets[len(offsets)-1], off)
}
offsets = append(offsets, off)
names = append(names, nameAt(off))
}
}
want := []string{"ann", "bee", "cee", "dee", "eff", "gee", "its", "jay", "mill", "zoo"}
if !slices.Equal(names, want) {
t.Fatalf("children in heap-offset order = %v, want %v", names, want)
}
// The root group's B-tree: one child pointer per leaf, keyed by the
// leaf's last offset. It is the tree with two children (each
// subgroup carries a tree of its own, with one).
treeAt := -1
for i := range hdf5ScanSignature(raw, []byte("TREE")) {
if raw[i+4] == 0 && int(binary.LittleEndian.Uint16(raw[i+6:])) == len(leaves) {
treeAt = i
break
}
}
if treeAt < 0 {
t.Fatal("no group B-tree carries the two root leaves")
}
prefix := make([]int, len(leaves)+1)
for i, at := range leaves {
prefix[i+1] = prefix[i] + int(binary.LittleEndian.Uint16(raw[at+6:]))
}
kids := int(binary.LittleEndian.Uint16(raw[treeAt+6:]))
p := treeAt + 24
// The node reads key0, then child and closing key per child.
if key := binary.LittleEndian.Uint64(raw[p:]); key != 0 {
t.Fatalf("the first B-tree key = %d, want the null name 0", key)
}
p += 8
prev := uint64(0)
for i := range kids {
child := int(binary.LittleEndian.Uint64(raw[p:]))
p += 8
if child != leaves[i] {
t.Fatalf("B-tree child %d points at %d, want the symbol node at %d", i, child, leaves[i])
}
key := binary.LittleEndian.Uint64(raw[p:])
p += 8
last := offsets[prefix[i+1]-1]
if key != last {
t.Fatalf("the closing key %d of child %d, want its leaf's last offset %d", key, i, last)
}
if key <= prev {
t.Fatalf("B-tree keys not ascending at child %d: %d then %d", i, prev, key)
}
prev = key
}
}
// hdf5ScanSignature yields the offsets of every signature block.
func hdf5ScanSignature(raw, sig []byte) func(yield func(int) bool) {
return func(yield func(int) bool) {
for i := 0; i+4 <= len(raw); i++ {
if string(raw[i:i+4]) == string(sig) {
if !yield(i) {
return
}
}
}
}
}
// hdf5FirstSignature returns the offset of the first signature block.
func hdf5FirstSignature(raw, sig []byte) int {
for at := range hdf5ScanSignature(raw, sig) {
return at
}
return -1
}
// TestHDF5WriteHeaderBoundRefusals pins the four bounds the header
// encoders and the plan enforce: a version 1 message past the
// countable length, a single version 2 message past its own countable
// length, an attribute name past the length the attribute message
// counts, and a text dataset past the rank the format allows.
func TestHDF5WriteHeaderBoundRefusals(t *testing.T) {
dir := t.TempDir()
values, err := core.FromFloats([]float64{1}, 1)
if err != nil {
t.Fatal(err)
}
huge := strings.Repeat("x", 70000)
for _, c := range []struct {
name string
write func() error
text string
}{
{"classic message past 0xffff", func() error {
return SaveHDF5(filepath.Join(dir, "v1.h5"),
[]HDF5Dataset{{Path: "/d", Shape: values.Shape(), Values: values, Attrs: map[string]string{"big": huge}}},
nil, HDF5WriteOptions{})
}, "a version 1 header stores"},
{"latest message past 0xffff", func() error {
return SaveHDF5(filepath.Join(dir, "v2.h5"),
[]HDF5Dataset{{Path: "/d", Shape: values.Shape(), Values: values, Attrs: map[string]string{"big": huge}}},
nil, HDF5WriteOptions{Latest: true})
}, "a version 2 header stores"},
{"attribute name past 0xffff", func() error {
return SaveHDF5(filepath.Join(dir, "name.h5"),
[]HDF5Dataset{{Path: "/d", Shape: values.Shape(), Values: values, Attrs: map[string]string{huge: "1"}}},
nil, HDF5WriteOptions{})
}, "attribute message counts"},
{"text dataset past the rank", func() error {
shape := make([]int, hdf5MaxRank+1)
return SaveHDF5Text(filepath.Join(dir, "rank.h5"),
[]HDF5TextDataset{{Path: "/t", Shape: shape, Text: nil}}, HDF5WriteOptions{})
}, "dimensions"},
} {
err := c.write()
if err == nil {
t.Errorf("%s: expected an error, got none", c.name)
continue
}
if !strings.Contains(err.Error(), c.text) {
t.Errorf("%s: error = %v, want it to name %q", c.name, err, c.text)
}
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
t.Errorf("a refused write left %q behind", e.Name())
}
}
// hdf5NarrowSets builds one dataset per dtype the narrow write round
// added, shaped [5, 3] with the values at each dtype's extremes beside
// deterministic filler. A small chunk target splits the trailing axis
// of this shape into one- or two-column chunks, so the round-trip
// configs below exercise multi-chunk grids and, for the one-byte
// dtypes, edge chunks that hang past the shape.
func hdf5NarrowSets(t *testing.T) []HDF5Dataset {
t.Helper()
must := func(values *core.Array, err error) *core.Array {
if err != nil {
t.Fatal(err)
}
return values
}
mk := []HDF5Dataset{}
add := func(path string, values *core.Array) {
mk = append(mk, HDF5Dataset{Path: path, Shape: values.Shape(), Values: values})
}
add("/bool", must(core.FromBools([]bool{
false, true, false,
true, true, false,
false, true, true,
false, true, false,
false, true, true}, 5, 3)))
add("/int8", must(core.FromInt8s([]int8{
-128, 127, 0,
-1, 7, -7,
42, -42, 100,
-100, 3, -3,
1, -56, 64}, 5, 3)))
add("/uint8", must(core.FromUint8s([]uint8{
0, 255, 1,
7, 42, 100,
3, 200, 64,
128, 12, 99,
254, 17, 8}, 5, 3)))
add("/int16", must(core.FromInt16s([]int16{
-32768, 32767, 0,
-1, 256, -256,
1000, -1000, 3,
-3, 32766, -32767,
17, 42, -7}, 5, 3)))
add("/uint16", must(core.FromUint16s([]uint16{
0, 65535, 1,
256, 1000, 3,
32768, 32767, 65534,
17, 42, 999,
8, 12, 5}, 5, 3)))
add("/int32", must(core.FromInt32s([]int32{
-2147483648, 2147483647, 0,
-1, 65536, -65536,
1000000, -1000000, 3,
-3, 2147483646, -2147483647,
17, 42, -7}, 5, 3)))
add("/uint32", must(core.FromUint32s([]uint32{
0, 4294967295, 1,
65536, 1000000, 3,
2147483648, 2147483647, 4294967294,
17, 42, 999999,
8, 12, 5}, 5, 3)))
return mk
}
// TestHDF5WriteNarrowRoundTrip writes every dtype the narrow write
// round added, contiguous in both layouts and chunked through the
// deflate and shuffle filters at several chunk targets, and pins the
// round trip: the landing dtype equals the written dtype (int16 reads
// back int16, bool lands bool) and every value returns exactly, at
// each dtype's extremes.
func TestHDF5WriteNarrowRoundTrip(t *testing.T) {
sets := hdf5NarrowSets(t)
for _, opts := range []HDF5WriteOptions{
{},
{Latest: true},
{Gzip: 1, ChunkBytes: 12},
{Shuffle: true, ChunkBytes: 12},
{Gzip: 6, Shuffle: true, ChunkBytes: 32},
} {
got := hdf5WriteRead(t, sets, nil, opts)
hdf5CompareSets(t, sets, got)
}
// Zero-length narrow datasets, shape [0]: one contiguous and one
// chunked config. The round trip has no values to compare, so the
// pin is the path, the shape and the landing dtype per dataset.
empty := hdf5NarrowEmptySets(t)
for _, opts := range []HDF5WriteOptions{
{},
{Gzip: 1, ChunkBytes: 12},
} {
got := hdf5WriteRead(t, empty, nil, opts)
hdf5CompareSets(t, empty, got)
}
}
// hdf5NarrowEmptySets builds one shape-[0] dataset per narrow dtype:
// the zero-extent round trip the narrow write path must also carry.
func hdf5NarrowEmptySets(t *testing.T) []HDF5Dataset {
t.Helper()
must := func(values *core.Array, err error) *core.Array {
if err != nil {
t.Fatal(err)
}
return values
}
mk := []HDF5Dataset{}
add := func(path string, values *core.Array) {
mk = append(mk, HDF5Dataset{Path: path, Shape: values.Shape(), Values: values})
}
add("/bool0", must(core.FromBools([]bool{}, 0)))
add("/int80", must(core.FromInt8s([]int8{}, 0)))
add("/uint80", must(core.FromUint8s([]uint8{}, 0)))
add("/int160", must(core.FromInt16s([]int16{}, 0)))
add("/uint160", must(core.FromUint16s([]uint16{}, 0)))
add("/int320", must(core.FromInt32s([]int32{}, 0)))
add("/uint320", must(core.FromUint32s([]uint32{}, 0)))
return mk
}
// TestHDF5WriteBoolMessage pins the boolean enumeration datatype
// message the writer emits, byte for byte, against the reader's own
// decoder: class 8 with two members in the class bit field and the
// reserved byte zero, a complete one-byte unsigned little-endian
// fixed-point base, the member names each NUL terminated and padded in
// their own field to a multiple of eight bytes, and the packed member
// values 0 and 1 behind the names. The payload bytes are the member
// values themselves.
func TestHDF5WriteBoolMessage(t *testing.T) {
values, err := core.FromBools([]bool{false, true, false}, 3)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "bool.h5")
if err := SaveHDF5(path, []HDF5Dataset{{Path: "/mask", Shape: values.Shape(), Values: values}}, nil, HDF5WriteOptions{}); err != nil {
t.Fatalf("SaveHDF5: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
pattern := []byte{
0x18, 0x02, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, // class 8, two members, one-byte values
0x10, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, // the unsigned base
'F', 'A', 'L', 'S', 'E', 0, 0, 0,
'T', 'R', 'U', 'E', 0, 0, 0, 0,
0x00, 0x01, // FALSE = 0, TRUE = 1
}
at := bytes.Index(raw, pattern)
if at < 0 {
t.Fatalf("no boolean enumeration datatype message in %d bytes", len(raw))
}
dtype, err := decodeType(raw[at:at+len(pattern)], false)
if err != nil {
t.Fatalf("decodeType on the written message: %v", err)
}
if dtype.class != 8 || !dtype.isBool || dtype.width != 1 {
t.Fatalf("datatype = class %d width %d isBool %v, want the one-byte boolean convention", dtype.class, dtype.width, dtype.isBool)
}
// The payload: the member values, one byte per element, which the
// round-trip test reads back as the written booleans.
if bytes.Index(raw, []byte{0x00, 0x01, 0x00}) < 0 {
t.Fatal("the boolean payload 0, 1, 0 is not in the file")
}
}
// TestHDF5WriteNarrowRefusals pins the refusal for a dtype outside the
// stored set: float16 and complex are named against the full list the
// writer stores, and the refusal happens before any bytes are written.
// The loud defaults of the payload and message switches are reached
// directly: a datatype class the plan never produces fails loudly
// rather than writing silent zeros or a file without a datatype
// message.
func TestHDF5WriteNarrowRefusals(t *testing.T) {
const stored = "the writer stores bool, int8, uint8, int16, uint16, int32, uint32, int64, float32 and float64"
dir := t.TempDir()
halves, err := core.FromFloat16s([]float64{1.5}, 1)
if err != nil {
t.Fatal(err)
}
complexes, err := core.FromComplexes([]complex128{1}, 1)
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
name string
values *core.Array
}{
{"float16", halves},
{"complex", complexes},
} {
path := filepath.Join(dir, c.name+".h5")
err := SaveHDF5(path, []HDF5Dataset{{Path: "/x", Shape: c.values.Shape(), Values: c.values}}, nil, HDF5WriteOptions{})
if err == nil {
t.Fatalf("%s: expected an unsupported-dtype refusal", c.name)
}
if want := "dtype " + c.name + " is not supported; " + stored; !strings.Contains(err.Error(), want) {
t.Fatalf("%s: error = %v, want it to carry %q", c.name, err, want)
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("%s: the refused write left %q behind", c.name, path)
}
}
unknown := &hdf5OutSet{path: "/bad", class: 2, width: 4, nbytes: 4}
if err := unknown.encode(make([]byte, 4)); err == nil || !strings.Contains(err.Error(), "cannot serialise") {
t.Fatalf("encode of class 2: err = %v, want the serialisation refusal", err)
}
w := &hdf5Writer{}
if _, err := w.writeDataset(unknown); err == nil || !strings.Contains(err.Error(), "no datatype message") {
t.Fatalf("writeDataset of class 2: err = %v, want the missing-message refusal", err)
}
odd := &hdf5OutSet{path: "/odd", class: 0, width: 3, nbytes: 3}
if err := odd.encode(make([]byte, 3)); err == nil || !strings.Contains(err.Error(), "cannot serialise") {
t.Fatalf("encode of a three-byte fixed-point payload: err = %v, want the serialisation refusal", err)
}
}
// TestHDF5WriteNarrowDeterministicBytes pins the determinism claim for
// the narrow dtypes: the same content supplied in different dataset
// orders writes byte for bit the same file, contiguous in both
// layouts, chunked through shuffle alone and chunked through shuffle
// and deflate.
func TestHDF5WriteNarrowDeterministicBytes(t *testing.T) {
sets := hdf5NarrowSets(t)
reordered := slices.Clone(sets)
slices.Reverse(reordered)
for _, opts := range []HDF5WriteOptions{
{},
{Latest: true},
{Shuffle: true, ChunkBytes: 12},
{Gzip: 4, Shuffle: true, ChunkBytes: 12},
} {
files := make([][]byte, 2)
for i, in := range [][]HDF5Dataset{sets, reordered} {
path := filepath.Join(t.TempDir(), "det.h5")
if err := SaveHDF5(path, in, nil, opts); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
files[i] = raw
}
if !bytes.Equal(files[0], files[1]) {
t.Fatalf("opts %v: two writes of the same narrow content differ in %d of %d bytes",
opts, countByteDiffs(files[0], files[1]), len(files[0]))
}
}
}