Author SHA1 Message Date
osobhandClaude Opus 4.8 8534c7d204 feat: migrate-engine improvements (content validation, schema, streaming, incremental)
clawhdf5-migrate:
- Real content validation: the post-migration check reads the written HDF5
  back (new hdf5_reader) and compares actual content — chunk text, embeddings,
  and every session/entity/relation field — to the source, not just row counts.
  A representative sample of chunk rows is verified by default; --validate-full
  checks every row. A count-preserving corruption no longer passes.
- Configurable schema: SQL is built from a SchemaConfig (table + ordered column
  names, defaulting to the ZeroClaw layout) instead of hardcoded queries, with
  --chunks-table / --sessions-table / --entities-table / --relations-table.
- Streaming count pass: --dry-run does a COUNT(*)-only pass per table instead
  of loading every row.
- Incremental migration: --incremental reads the existing output, reads only
  source chunks with id greater than the last migrated id, and appends them
  (metadata groups refreshed from source) rather than re-migrating everything.

clawhdf5-format:
- read_as_f32 / read_as_f64 now decode IEEE-754 half-precision (2-byte) floats
  via a no_std-safe bit conversion — needed to read float16-stored embeddings
  back (e.g. for migrate's content validation), previously a TypeMismatch.

Tests: f16 read unit test; migrate tests for content-corruption detection,
custom table names, and incremental append; CLI smoke-tested end-to-end and the
dense/incremental output verified with h5py.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-04 02:19:31 +00:00
osobhandClaude Opus 4.8 0754afb7f2 feat: write multi-block fractal heaps (root indirect block)
Dense attribute and dense link storage capped at a single fractal-heap direct
block (~64 KiB of heap data — a few thousand objects); beyond that the writer
produced an invalid oversized block. Lift the cap with a root indirect block.

When the serialized objects don't fit in one direct block, build a root
indirect block (FHIB) over multiple direct blocks sized by the doubling table
(start 512, width 4, doubling per row up to 64 KiB). Objects are packed
row-major across blocks, each block carries its logical block offset, and heap
IDs encode each object's heap offset (block offset + position). The FRHP points
root -> FHIB with the row count; unused slots in the current rows are undefined.

The fractal-heap builder is unified: FractalHeapBlock now carries the full heap
blob, and a shared write_frhp helper serializes the header for both the
single-block and multi-block paths. The single-block path is unchanged
(byte-identical), so existing dense attrs/links stay valid.

Validated end-to-end: a 2,500-attribute object and a 2,500-link group round-trip
through our reader and are read correctly by h5py. Objects still may not span a
block (no huge-object path).

Tests: facade round-trips for multi-block dense attrs and dense links, plus an
h5py-gated interop test (verified against the real h5py environment).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-04 01:37:42 +00:00
osobhandClaude Opus 4.8 0aab49f2f0 fix: read multi-direct-block fractal heaps (root indirect block)
The fractal-heap reader split direct vs indirect block rows using the FRHP
"Starting # of Rows in Root Indirect Block" field (a constant, typically 1),
mislabeled as starting_row_of_indirect_blocks. For any heap whose data spans
more than one direct block — common in libhdf5 files with a large group or
many dense attributes — this treated direct blocks as indirect and walked into
garbage, failing with InvalidFractalHeapSignature.

Derive the split from the heap geometry instead: max_direct_rows =
log2(max_direct_block_size / starting_block_size) + 2. Rows below it hold
direct blocks; rows at/above hold child indirect blocks.

Validated against an h5py-written group with 400 dense attributes (root
indirect block, 4 rows, 13 direct blocks): all values now read correctly.
Regression fixture covers an 80-attribute multi-block heap.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-04 01:27:45 +00:00
osobhandClaude Opus 4.8 20ad16ab69 feat: write dense group link storage (fractal heap + v2 B-tree)
A group with more than 8 links (libhdf5's compact max_compact default) is now
written densely instead of as inline Link messages: the links live in a
single-direct-block fractal heap indexed by a v2 B-tree of type 5 (link-name
index), referenced from the group's LinkInfo message. This matches libhdf5's
compact->dense switchover and keeps large groups out of the object header.

- Extract the byte-identical fractal-heap builder shared by dense attributes
  and dense links, parameterized by heap_id_length / max_heap_size. Attributes
  keep 8 / 40; links use 7 / 32 to match libhdf5 (reverse-engineered: an
  h5py-written dense group uses heap_id_length 7, max_heap_size 32, type-5
  record = hash(4) + heap_id(7) = 11 bytes). This was the cause of an initial
  "object overruns end of direct block" error from h5py.
- build_group_oh gained an optional dense LinkInfo (omitting inline Link
  messages); the two-pass file assembly allocates each group's link blob after
  its object header and rebuilds it with real target addresses in the final
  pass (link-message size is address-independent, so layout is stable).

Validated end-to-end: our reader round-trips dense groups, h5py reads the
groups we write, and dense attributes remain byte-identical (still h5py-valid).
The agent's 9-dataset memory group now writes densely and round-trips. Single
direct block only (~a couple thousand links); indirect blocks remain a TODO.

Tests: facade round-trip (20-link dense group + compact sibling) and an
h5py-gated interop test confirming libhdf5 reads our dense groups.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-04 01:12:23 +00:00
osobhandClaude Opus 4.8 e0189cd5c4 harden: make the new readers panic-free on malformed input
The readers added this cycle parse untrusted bytes, so malformed/hostile
input must produce errors — never a panic, OOM, or unbounded recursion.
Audited each new surface and fixed the concrete vectors, each covered by an
adversarial regression test:

- Paged Fixed Array: `1 << max_nelmts_bits` shift overflow (u8 up to 255);
  element-count bounded by file size; element/page offset multiplies checked.
- H5S selection decoder: ALL/NONE validate they have the 16 bytes they claim
  to consume; hyperslab rank capped at 32 (H5S_MAX_RANK); iter_linear
  coordinate/stride/product arithmetic uses checked ops.
- VDS mapping parser: drop pre-allocation from the untrusted `nused`;
  bounds-check all selection slicing.
- scale-offset / N-Bit filters: `1 << minbits` overflow at minbits==64; N-Bit
  `bit_offset + precision` overflow; N-Bit type-tree recursion depth capped to
  stop a crafted nested tree from overflowing the stack; element counts bounded
  by the chunk's expected decompressed size (threaded the previously-unused
  chunk_size into both decoders) so a bogus count can't over-allocate.
- VDS assembly: a virtual dataset whose source is itself virtual (a cycle) now
  errors instead of recursing into a stack overflow.

16 new adversarial tests; full format suite (482 lib) + agent + facade green;
clippy clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-04 00:28:33 +00:00
osobhandClaude Opus 4.8 4fa7e89a46 feat: compress fixed-length string datasets (+ fix shared chunk-cache bug)
clawhdf5-agent: fixed-length string datasets (memory text chunks, session
summaries, ids, tags, entity/relation names) were stored uncompressed behind
a stale "chunked compound not yet supported" comment. Chunked writes work for
fixed-size string/compound datatypes like any other, so write_string_dataset
now chunks + deflates once a dataset's payload reaches 4 KiB — large,
redundant NullPad content compresses well while tiny metadata stays
contiguous (no chunk-overhead bloat). The dead `compress` parameter is
removed in favor of this size heuristic.

clawhdf5-format: enabling string compression exposed a latent bug — the
per-file ChunkCache built its chunk index once and reused it for every
chunked dataset in the file, keyed only by chunk coordinate with no dataset
discrimination. With one chunked dataset per file this never surfaced; with
two of different rank (a 1-D compressed string array and the 2-D embeddings
matrix) the first dataset's rank-1 index was reused for the second, panicking
with an out-of-bounds chunk coordinate. The cache now binds to a dataset by
its chunk-index address and rebinds — dropping the index, chunk-index map,
layout, and decompressed slots — whenever the dataset being read changes,
while still caching repeated/sequential access to the same dataset.

Tests: facade regression reading a 1-D compressed string dataset and a 2-D
compressed f32 dataset through one shared File cache (verified to panic
without the fix); existing agent e2e tests (large text chunks, migration
round-trip) now pass with compression on.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 22:48:39 +00:00
osobhandClaude Opus 4.8 57adc88320 test: regression for scale-offset float E-scale (raw + masked filter)
The HDF5 library does not implement the scale-offset filter's floating-point
E-scale mode. When asked for it (cd_values[0] = 1) it stores the chunk raw
(no minbits/minval header) and sets the chunk filter mask to skip the filter,
so such datasets read back verbatim purely by honoring the per-chunk filter
mask — no E-scale decoder is required.

Add a fixture written via the HDF5 low-level API (exact-representable values)
and a test asserting it reads back verbatim, locking in the filter-mask path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 22:14:01 +00:00
osobhandClaude Opus 4.8 e6f0d8f161 feat: read external-file Virtual Datasets via a source resolver
VDS sources living in other files were previously unsupported because the
pure-byte read API has no filesystem. Add a resolver seam and wire a default.

clawhdf5-format:
- Add VdsSourceResolver (Fn(&str) -> Option<Vec<u8>>) and
  read_raw_data_full_with_resolver. read_virtual_data uses the resolver to
  fetch an external source file's bytes by its stored name, then reads the
  named source dataset from those bytes and scatters as usual. A resolver
  returning None leaves the region at fill (HDF5's missing-source behavior);
  an external source with no resolver at all is a clean error. read_raw_data_full
  is unchanged (delegates with no resolver).

clawhdf5:
- File now records the directory it was opened from and, for virtual layouts,
  reads through a default resolver that loads sibling source files relative to
  that directory. So File::open(virt).dataset(d).read_*() transparently
  assembles cross-file VDS. In-memory files (from_bytes) have no directory, so
  only same-file VDS resolves there.

Tests: format-layer external read with an injected resolver (and the
no-resolver error path), plus facade tests that drop both files in a temp dir
and read through File::open — covering successful resolution and the
missing-source-is-fill case.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 21:19:36 +00:00
osobhandClaude Opus 4.8 98ccc69411 feat: extend same-file VDS assembly to N dimensions
Generalize Selection iteration from 1-D to arbitrary rank: iter_linear(dims)
enumerates a selection's row-major linear indices over a dataspace of the
given shape (ALL, NONE, regular hyperslabs, points), which is the order HDF5
uses to pair virtual and source selections.

read_virtual_data now passes the full virtual/source dimensions instead of a
single extent, so multi-dimensional block mappings scatter to the correct
non-contiguous linear positions. read_named_dataset_raw returns the source
dataset's dimensions. The rank-1 restriction is removed; only external-file
sources remain unsupported.

Tests: 2-D integration fixture (vds_2d_same_file.h5: two 2x2 sources placed
as non-contiguous blocks in a 4x4 virtual) plus N-D iter_linear unit tests
(block, strided, ALL, rank-mismatch). The 1-D path is unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 20:57:12 +00:00
osobhandClaude Opus 4.8 908af40282 feat: assemble 1-D same-file Virtual Datasets (VDS)
A virtual layout previously returned UnsupportedVersion. Implement reading
for the common 1-D, same-file case, reverse-engineered and validated against
HDF5 2.0.

- Rewrite parse_vds_mappings to the real global-heap block format
  (version(1) · nused(length_size) · entries · checksum(4)), where each
  entry is source-file(null) · source-dataset(null) · source-selection ·
  virtual-selection. Block version 1 encodes a same-file source as a single
  0x04 marker in place of the file name; version 0 stores an explicit file
  name. The selections are H5S-serialized and self-describing in length, so
  they are decoded to find entry boundaries. The previous parser used a
  guessed layout that did not match real files.

- Extend Selection with decode_serialized() (H5S_select_serialize: ALL,
  NONE, and version-3 regular hyperslabs) and iter_linear_1d().

- Add read_virtual_data: resolve the mapping block from the global heap,
  read each same-file source dataset, and scatter its selected elements into
  the virtual buffer; unmapped regions stay at the zero fill value.
  External-file sources and N-D selections return a clean unsupported error.

Tests: real-file integration test (vds_same_file.h5: partial source slice +
fill gap), selection decoder unit tests built from the fixture bytes, and
same-file/external mapping-parser unit tests.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 20:01:51 +00:00
osobhandClaude Opus 4.8 a24fcb8be4 feat: read paged Fixed Array chunk indexes
A filtered, fixed-dimension dataset with more than one Fixed Array
data-block page (>1024 chunks by default) previously failed with
"paged Fixed Array data blocks not yet supported".

Implement the paged data-block layout, reverse-engineered and validated
against an HDF5 2.0 file:
- after the FADB prefix: a page-init bitmap (one bit per page, MSB-first
  within each byte), a 4-byte checksum, then the pages;
- each page is a fixed full-size slot of page_nelmts elements plus a
  4-byte checksum, with only the final page shorter;
- uninitialized pages still occupy their slot (zero-filled), so the
  bitmap — not a 0xFF sentinel — marks a whole page unallocated.

Element parsing is factored into parse_fa_element, shared by the
non-paged and paged paths.

Tests: real-file integration test against a minimal 2-page gzip fixture
(v4_fixed_array_paged.h5) plus a synthetic unit test covering a
multi-byte/MSB-first bitmap, a skipped uninitialized page, and a short
final page.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 17:41:31 +00:00
osobhandClaude Opus 4.8 4b1f4e369a docs: changelog for array-typed datatype reads
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 20:12:33 +00:00
osobhandClaude Opus 4.8 c99fb39ffd feat: read array-typed datatypes (incl. array compound members)
The typed read paths (read_as_i32/i64/u64/f32/f64) rejected Array datatypes
with a TypeMismatch, so an array-typed compound member (common with N-Bit /
reduced-precision data) could not be read. They now unwrap an Array to its base
type and read the flat sequence of base elements, recursing for nested arrays.
Base-type precision rules (e.g. reduced-precision sign extension) apply to the
elements.

Validated end-to-end against an HDF5 2.0 compound with an array member: the
array field reads [-1, 100, 1000, -32768] with correct 16-bit sign extension.
Adds a regression test for flat and nested array reads.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 20:12:21 +00:00
osobhandClaude Opus 4.8 dbd683dcaf docs: changelog for compound/array N-Bit support
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 13:04:22 +00:00
osobhandClaude Opus 4.8 06a1ef5285 feat: decode compound and array N-Bit layouts
Generalizes the N-Bit filter (id 5) decoder from atomic-only to the full
recursive type tree carried in the filter client data: atomic
([1, size, order, precision, offset]), array ([2, total_size, <base>]) and
compound ([3, total_size, nmembers, (offset, <node>)*]), nestable to any depth.

The decoder parses the tree once, then walks it per element with an MSB-first
bit reader, placing each leaf field's significant bits at its byte/bit offset in
a zero-filled element — HDF5's canonical layout. Float members are encoded as
full-precision atomics and handled transparently. Validated end-to-end against
HDF5 2.0 / h5py: compound int+int, compound with an array member, and compound
with a float member all decode to the exact canonical bytes. Adds h5py-free unit
tests from real captured chunks. (Reading array-typed compound *fields* into a
flat buffer is a separate datatype-reader concern.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 13:04:08 +00:00
osobhandClaude Opus 4.8 8c68b5de33 docs: changelog for reduced-precision integer sign-extension
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:48:23 +00:00
osobhandClaude Opus 4.8 249841e232 fix: sign-extend reduced-precision fixed-point integers on read
HDF5 stores a fixed-point value whose datatype precision is smaller than its
storage size zero-filled above the precision; the sign of a reduced-precision
signed integer lives in the precision field, not the storage word, and is
applied during datatype conversion. clawhdf5 previously read the full storage
word, so e.g. a 16-bit-precision -1 (stored 0x0000ffff) read as 65535.

The integer read paths (read_as_i32/i64/u64/f32/f64) now extract the
[bit_offset, bit_offset+bit_precision) field and sign-extend (signed) or mask
(unsigned). Full-width types are unchanged — the bulk-copy fast paths are gated
to full width, so the common case keeps its memcpy and behaviour.

This completes signed N-Bit reads (now exact end-to-end) and also fixes
un-filtered reduced-precision signed/unsigned integer datasets. Validated
against HDF5 2.0 / h5py; adds h5py-free regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:48:08 +00:00
osobhandClaude Opus 4.8 bff039fa29 docs: changelog for float D-scale and N-Bit filter support
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:36:50 +00:00
osobhandClaude Opus 4.8 4a5ab1c584 feat: decode the HDF5 N-Bit filter (atomic variant)
Implements decompression for the N-Bit filter (id 5), atomic integer/float
variant — previously returned UnsupportedFilter(5). N-Bit packs each element's
significant `precision` bits MSB-first with no header; decode reads those bits
per element and places them at the datatype's bit offset in a zero-filled
`size`-byte element, reproducing HDF5's canonical reduced-precision layout
(verified byte-for-byte against the equivalent un-filtered dataset).

Reverse-engineered and validated against HDF5 2.0 / h5py: unsigned
reduced-precision datasets now read end-to-end with exact values. Signed
reduced-precision values are restored to their canonical (zero-filled) bytes;
sign-extending them to the application width is the datatype reader's job — a
pre-existing concern shared with un-filtered reduced-precision data. Recursive
compound/array N-Bit layouts remain unsupported. Adds h5py-free unit tests from
real captured chunks.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:36:13 +00:00
osobhandClaude Opus 4.8 9062b3fb53 feat: decode the float D-scale scale-offset variant
Extends the scale-offset filter (id 6) decoder to the floating-point D-scale
variant (H5Z_SO_FLOAT_DSCALE) alongside the integer variant. Shares the header
parsing and MSB-first code unpacking; reconstruction is
`value = minval + code / 10^scale_factor`, where `minval` is the minimum float
stored in the header and the all-ones code is the (defined) fill value.

Reverse-engineered and validated against HDF5 2.0 / h5py across f32 and f64,
negatives, decimal scale factors D=1..5 and multi-chunk datasets (decoded values
match h5py to full precision). The float E-scale variant remains unsupported.
Adds h5py-free unit tests from real captured chunks.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:28:20 +00:00
osobhandClaude Opus 4.8 ec7357de45 docs: changelog entry for scale-offset filter support
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:18:55 +00:00
osobhandClaude Opus 4.8 6ab42c2f07 feat: decode the HDF5 scale-offset filter (integer variant)
Implements decompression for the scale-offset filter (id 6), integer mode
(H5Z_SO_INT) — previously returned UnsupportedFilter(6). The on-disk format was
reverse-engineered against HDF5 2.0 / h5py and verified across signed/unsigned
element sizes, negative minima, multi-chunk datasets and fill-value handling:

  minbits (u32 LE) | 0x08 | minval (8 bytes LE) | 8 reserved bytes |
  MSB-first packed codes (nelmts * minbits bits)

Each code is `value - minval`; the all-ones code is reserved for the (defined)
fill value. The floating-point variants (D-scale/E-scale) use a different
algorithm and remain reported as unsupported.

Validated end-to-end (a 200-element chunked scale-offset dataset, plus negative
and unsigned datasets, now decode to the exact h5py values). Adds h5py-free unit
tests using real captured compressed chunks.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:18:42 +00:00
osobhandClaude Opus 4.8 19ca662975 docs: changelog entry for HDF5 2.0 (version-5) read fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:01:58 +00:00
osobhandClaude Opus 4.8 bc3a3a977a fix: read array datatypes and chunked layouts from HDF5 2.0 (version 5)
Follow-up to the v5 compound fix, found by an interop sweep over diverse
h5py/HDF5 2.0 (libver=latest) datasets:

- Array datatype (class 10) version 5 was rejected. v3/v4/v5 share the same
  array encoding, so the parser now accepts 3-5.
- Data Layout message version 5 was rejected, which broke EVERY chunked/
  compressed dataset written by modern HDF5. v5 reuses the v4 message
  structure, so it now routes through parse_v4.

Validated end-to-end: a gzip-compressed, Fixed-Array-indexed v5 chunked dataset
now decodes to the correct values. Adds h5py-free regression tests using the
real v5 array-datatype and chunked-layout bytes, and updates the layout
invalid-version test to use v6.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:00:38 +00:00
osobhandClaude Opus 4.8 a13ff51918 fix: read compound datatypes from HDF5 1.14+/2.0 (datatype version 5)
clawhdf5 rejected datatype message version 5 for the compound class with
"invalid datatype version 5 for class 6", so it could not read compound
datasets written by modern HDF5 / h5py with libver=latest. v5 reuses the same
compact member encoding as v3/v4 (name, variable-width offset, member type), so
the parser now accepts versions 3-5 for compound.

Found by running the previously-ignored h5py interop tests against h5py 3.16 /
HDF5 2.0. Adds an h5py-free regression test using the real v5 datatype bytes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:55:15 +00:00
osobhandClaude Opus 4.8 3ff501c8ef docs: add Unreleased changelog section for post-2.1.0 changes
Records the parallel chunk-compression perf change and the doc sweep that
landed after the v2.1.0 tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:42:43 +00:00
osobhandClaude Opus 4.8 49a99a9a40 docs: document hnsw/format feature flags and missing agent modules
- Add the `hnsw` flag (default-on) to the agent feature table and the
  fast-deflate/system-zlib/fast-checksum/lz4/zstd/blake3 flags to the format
  table.
- Add entity_extract and async_memory to the agent module overview.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:40:41 +00:00
osobhandClaude Opus 4.8 b9fac46ea5 perf: wire parallel chunk compression into the write path
build_chunked_data_at_ext now compresses all chunks via compress_all_chunks
(previously dead code) before laying them out, so compression runs across
rayon threads under the `parallel` feature when there are >4 filtered chunks.
Layout is unchanged — compression preserves chunk order, so on-disk bytes are
identical to the sequential path. The agent crate enables `parallel`, so this
speeds up compressed embedding writes.

Removes the #[allow(dead_code)] on compress_all_chunks and gates
PARALLEL_COMPRESS_THRESHOLD behind the `parallel` feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:40:40 +00:00
osobhandClaude Opus 4.8 f1762f82a7 docs: fix stale package names, counts, and CLI subcommands
Sweep of the docs after the v2.0.0 rename and recent changes:
- Per-crate READMEs (13 files): rename leftover rustyhdf5-*/edgehdf5-*
  package names and badges to clawhdf5-*, bump usage versions to 2.1.0.
- README: update stale test badge (417 -> 1500+), workspace stats
  (15 crates/72K -> 17 crates/84K), agent crate stats (20.7K/32 modules),
  and add the missing clawhdf5-napi and clawhdf5-bench crates to the tree.
- CLAUDE.md: correct the CLI subcommand list (inspect/dump/index/search ->
  the actual create/save/search/recall/stats/flush-wal/agents-md/export/snapshot).

No code changes. Verified there are zero todo!()/unimplemented!() macros and
no TODO/FIXME comments in the tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:17:04 +00:00
44 changed files with 4213 additions and 546 deletions
+176
View File
@@ -1,5 +1,181 @@
# Changelog # Changelog
## Unreleased
### New Features
- `clawhdf5-migrate`: substantial engine improvements:
- **Real content validation** — the post-migration check now reads the written
HDF5 back and compares actual content (chunk text, embeddings, and every
session/entity/relation field) against the source, not just row counts. A
representative sample of chunk rows is verified by default; `--validate-full`
checks every row. A corrupt migration that preserves counts no longer passes.
- **Configurable schema** — table names are no longer hardcoded; queries are
built from a `SchemaConfig` (table + ordered column names, defaulting to the
ZeroClaw layout) with `--chunks-table` / `--sessions-table` /
`--entities-table` / `--relations-table` overrides.
- **Streaming count pass** — `--dry-run` now does a `COUNT(*)`-only pass per
table instead of loading every row into memory.
- **Incremental migration** — `--incremental` reads the existing output, reads
only source chunks newer than the last migrated id, and appends them
(refreshing the metadata groups), instead of re-migrating everything.
- `clawhdf5-format`: read **IEEE-754 half-precision (f16)** floats. `read_as_f32`
/ `read_as_f64` previously only handled 4- and 8-byte floats; 2-byte floats
(e.g. float16-stored embeddings) now decode via a no_std-safe bit conversion.
- `clawhdf5-format`: **write multi-block fractal heaps** (root indirect block).
Dense attribute and dense link storage previously capped at a single direct
block (~64 KiB of heap data — a few thousand attributes/links). When the
objects exceed one direct block, the heap now lays out a root indirect block
(FHIB) over multiple direct blocks sized by the doubling table, distributing
objects across blocks with correct per-block heap offsets. Validated
end-to-end: a 2,500-attribute object and a 2,500-link group round-trip
through our reader and are read correctly by h5py. (Objects still may not
span a block — no huge-object path.)
- `clawhdf5-format`: **write dense group link storage** (fractal heap + v2
B-tree). A group with more than 8 links (libhdf5's compact `max_compact`
default) is now written densely — its links live in a fractal heap indexed by
a v2 B-tree of type 5 (link-name index) referenced from the group's LinkInfo
message — instead of as inline Link messages. This matches libhdf5's
compact→dense switchover and keeps large groups out of the object header.
Reverse-engineered against libhdf5: link heaps use `heap_id_length` 7 /
`max_heap_size` 32 (vs 8 / 40 for attributes). The shared single-direct-block
fractal-heap builder is now parameterized and used by both dense attributes
and dense links. Validated end-to-end: our reader round-trips, and h5py reads
the dense groups we write. (Single direct block — up to ~a couple thousand
links per group; beyond that needs indirect blocks, still unsupported.)
### Robustness
- `clawhdf5-format`: harden the readers added this cycle against malformed /
hostile input — they parse untrusted bytes and must return errors, never
panic, OOM, or recurse without bound. Fixed concrete vectors found by audit
and locked in with adversarial tests:
- **Paged Fixed Array**: `1 << max_nelmts_bits` shift overflow (a `u8` ≥ 64);
element/page offset multiplications now checked; element count bounded by
file size.
- **H5S selection decoder**: `ALL`/`NONE` no longer claim 16 bytes they don't
have; hyperslab `rank` capped at 32 (`H5S_MAX_RANK`) to stop a giant
allocation; `iter_linear` coordinate/stride/product arithmetic is checked.
- **VDS mapping parser**: no pre-allocation from the untrusted `nused`; all
selection slicing is bounds-checked.
- **scale-offset / N-Bit filters**: `1 << minbits` overflow at `minbits == 64`;
N-Bit `bit_offset + precision` overflow; N-Bit type-tree recursion depth
capped (no stack overflow from a crafted nested tree); element counts
bounded by the chunk's expected decompressed size so a bogus count can't
drive a huge allocation.
- **Virtual Dataset assembly**: a virtual dataset whose source is itself
virtual (a cycle) now errors instead of recursing into a stack overflow.
### New Features
- `clawhdf5-agent`: **compress fixed-length string datasets** (memory text
chunks, session summaries, ids, tags, entity/relation names, …). These were
always stored uncompressed with a "chunked compound not yet supported" note
that was simply stale — chunked writes work for fixed-size string/compound
datatypes like any other. `write_string_dataset` now chunks + deflates a
string dataset once its payload reaches 4 KiB, so large, highly-redundant
NullPad content shrinks substantially while tiny metadata stays contiguous
(no chunk-overhead bloat).
- `clawhdf5-format`: decode the **scale-offset filter** (id 6) — both the
integer variant (`H5Z_SO_INT`) and the floating-point **D-scale** variant
(`H5Z_SO_FLOAT_DSCALE`). Handles signed/unsigned int sizes, f32/f64, negative
minima, decimal scale factors and fill values; reverse-engineered against
HDF5 2.0 and validated end-to-end. The float E-scale variant remains
unsupported.
- `clawhdf5-format`: decode the **N-Bit filter** (id 5) — atomic, **compound**
and **array** layouts (the full recursive type tree, nestable to any depth),
previously unsupported. Signed and unsigned reduced-precision integers and
float members all read end-to-end, validated against HDF5 2.0.
### New Features
- `clawhdf5` / `clawhdf5-format`: read **external-file Virtual Datasets (VDS)**.
The format layer gains `read_raw_data_full_with_resolver` and a
`VdsSourceResolver` callback (`Fn(&str) -> Option<Vec<u8>>`) that maps a
stored source file name to its bytes, so the pure-byte reader can pull in
external sources without a filesystem of its own. The `clawhdf5` `File` API
wires a default resolver that reads sibling source files relative to the
opened file's directory, so `File::open(...).dataset(...).read_*()` now
transparently assembles cross-file VDS. A source file the resolver cannot
supply leaves its region at the fill value (matching HDF5); an external
source with no resolver at all is a clean error. In-memory files
(`File::from_bytes`) have no directory, so only same-file VDS resolves there.
- `clawhdf5-format`: assemble **same-file Virtual Datasets (VDS)** of any rank.
Previously a virtual layout returned `UnsupportedVersion`. The reader now
decodes the global-heap mapping block (reverse-engineered against HDF5 2.0:
`version · nused · [source-file · source-dataset · source-selection ·
virtual-selection]* · checksum`, including the block-version-1 same-file
marker), decodes the `H5S` source/virtual dataspace **selections** (ALL,
NONE, and version-3 regular hyperslabs), reads each same-file source dataset,
and scatters its selected elements into the virtual buffer in row-major order
(so multi-dimensional block mappings land correctly); unmapped regions are
left at the zero fill value. External-file sources return a clean unsupported
error. The previous `parse_vds_mappings` used a guessed layout that did not
match real files and is replaced.
### Tests
- `clawhdf5-format`: regression test for **scale-offset float E-scale**
datasets. The HDF5 library does not implement E-scale encoding — when asked
for it (`cd_values[0] = 1`) it stores the chunk raw and sets the chunk filter
mask to skip the filter — so these files read back verbatim purely by
honoring the per-chunk filter mask. The test locks in that behavior against a
fixture produced via the HDF5 low-level API; no E-scale decoder is needed.
### Bug Fixes
- `clawhdf5-format`: **read multi-direct-block fractal heaps**. The reader split
direct vs indirect block rows using the FRHP "Starting # of Rows in Root
Indirect Block" field (a constant, typically 1), so any heap whose data spans
more than one direct block — common in libhdf5 files with a large group or
many dense attributes — was misread as having indirect blocks and failed with
`InvalidFractalHeapSignature`. The split is now derived from the heap geometry
(`max_direct_rows = log2(max_direct / start) + 2`). Validated against an
h5py-written 400-dense-attribute group (root indirect block, 4 rows, 13 direct
blocks).
- `clawhdf5-format`: scope the per-file **chunk cache by dataset**. The shared
`ChunkCache` built its chunk index once and reused it for every chunked
dataset in the file, keyed only by chunk coordinate with no dataset
discrimination. With a single chunked dataset per file this was latent; once a
file holds two chunked datasets of different rank (e.g. a 1-D compressed
string array and the 2-D embeddings matrix), the first dataset's index was
reused for the second, panicking with an out-of-bounds chunk coordinate. The
cache now rebinds (dropping its index, chunk-index map, layout, and
decompressed slots) whenever the dataset being read changes, while still
caching repeated/sequential access to the same dataset.
- `clawhdf5-format`: read **paged Fixed Array** chunk indexes. A filtered,
fixed-dimension dataset with more than one data-block page (>1024 chunks by
default) previously failed with "paged Fixed Array data blocks not yet
supported". The reader now walks the page-init bitmap (MSB-first), skips
uninitialized pages, and resolves each page's fixed full-size slot (including
the short final page). Reverse-engineered and validated end-to-end against an
HDF5 2.0 file.
- `clawhdf5-format`: read **array-typed datatypes** (e.g. an array-typed
compound member) via `read_as_i32/i64/u64/f32/f64` — previously a
`TypeMismatch`. The array is read as a flat sequence of its base elements
(recursing for nested arrays), applying base-type precision rules.
- `clawhdf5-format`: **sign-extend reduced-precision fixed-point integers** on
read. A signed integer whose datatype precision is smaller than its storage
size is stored zero-filled, so e.g. a 16-bit-precision `-1` previously read as
`65535`. The integer read paths now extract the precision field and
sign-extend (full-width types are unchanged). Completes signed N-Bit reads and
also fixes un-filtered reduced-precision integer datasets.
- `clawhdf5-format`: read datasets written by modern HDF5 (1.14+/2.0, i.e.
`libver=latest`). Compound (class 6) and array (class 10) datatype **version 5**
messages and data layout **version 5** messages were rejected as invalid; they
reuse the v3/v4 binary structure, so they are now accepted. This unblocks
reading compound types and — critically — every chunked/compressed dataset
written by HDF5 2.0. Found by running the h5py interop tests against
h5py 3.16 / HDF5 2.0.
### Performance
- `clawhdf5-format`: chunked writes now compress all chunks up front via
`compress_all_chunks`, running across rayon threads under the `parallel`
feature when there are more than 4 filtered chunks. On-disk layout is
unchanged. Speeds up compressed embedding writes in `clawhdf5-agent` (which
enables `parallel`).
### Documentation
- Fix stale package names across all 13 per-crate READMEs (`rustyhdf5-*` /
`edgehdf5-*``clawhdf5-*`, usage versions → 2.1.0).
- Correct README workspace/test/crate stats and the CLAUDE.md CLI subcommand
list; document the `hnsw` and format compression/checksum feature flags and
the `entity_extract` / `async_memory` modules.
## v2.1.0 (2026-06-03) ## v2.1.0 (2026-06-03)
### New Features ### New Features
+1 -1
View File
@@ -54,7 +54,7 @@ cargo test --workspace
### CLI ### CLI
```bash ```bash
cargo run -p clawhdf5-cli -- --help cargo run -p clawhdf5-cli -- --help
# inspect, dump, index, search subcommands # create, save, search, recall, stats, flush-wal, agents-md, export, snapshot subcommands
``` ```
### Python bindings ### Python bindings
+21 -7
View File
@@ -4,7 +4,7 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.75%2B-orange.svg)](https://www.rust-lang.org) [![Rust](https://img.shields.io/badge/rust-1.75%2B-orange.svg)](https://www.rust-lang.org)
[![Tests](https://img.shields.io/badge/tests-417%20passing-brightgreen.svg)](#benchmarks) [![Tests](https://img.shields.io/badge/tests-1500%2B%20passing-brightgreen.svg)](#benchmarks)
[![LongMemEval](https://img.shields.io/badge/LongMemEval-Hit@5%2046%25%20BM25--only-blue.svg)](BENCHMARKS.md#longmemeval-results) [![LongMemEval](https://img.shields.io/badge/LongMemEval-Hit@5%2046%25%20BM25--only-blue.svg)](BENCHMARKS.md#longmemeval-results)
[![Footprint](https://img.shields.io/badge/footprint-6.5%20KB%2Frecord-lightgrey.svg)](BENCHMARKS.md#memory-footprint) [![Footprint](https://img.shields.io/badge/footprint-6.5%20KB%2Frecord-lightgrey.svg)](BENCHMARKS.md#memory-footprint)
@@ -170,9 +170,11 @@ ClawhDF5's agent memory engine implements research from 15+ recent papers on age
| **`vector_search`** | Flat cosine, pre-normed, SIMD, BLAS, GPU, parallel search paths | | **`vector_search`** | Flat cosine, pre-normed, SIMD, BLAS, GPU, parallel search paths |
| **`ivf` / `pq`** | IVF-PQ approximate nearest neighbor for billion-scale search | | **`ivf` / `pq`** | IVF-PQ approximate nearest neighbor for billion-scale search |
| **`bm25`** | BM25 keyword index with TF-IDF scoring | | **`bm25`** | BM25 keyword index with TF-IDF scoring |
| **`entity_extract`** | Rule-based entity extraction from text chunks into the knowledge graph |
| **`wal`** | Write-ahead log for crash-safe persistence | | **`wal`** | Write-ahead log for crash-safe persistence |
| **`memory_strategy`** | Pluggable strategies: save-every, semantic-shift, user-correction detection | | **`memory_strategy`** | Pluggable strategies: save-every, semantic-shift, user-correction detection |
| **`decision_gate`** | Sub-microsecond trivial/substantive classification | | **`decision_gate`** | Sub-microsecond trivial/substantive classification |
| **`async_memory`** | Tokio-based async wrapper over the memory store (`async` feature) |
--- ---
@@ -313,7 +315,7 @@ let exported = backend.export_markdown("MEMORY.md")?;
## Crate Map ## Crate Map
``` ```
clawhdf5 workspace (15 crates, 72K lines of Rust) clawhdf5 workspace (17 crates, 84K lines of Rust)
├── Core HDF5 ├── Core HDF5
│ ├── clawhdf5-types — Type system definitions │ ├── clawhdf5-types — Type system definitions
@@ -327,14 +329,18 @@ clawhdf5 workspace (15 crates, 72K lines of Rust)
│ └── clawhdf5-gpu — GPU compute (wgpu) │ └── clawhdf5-gpu — GPU compute (wgpu)
├── Agent Memory ├── Agent Memory
│ ├── clawhdf5-agent — Memory engine (16.8K lines, 29 modules) │ ├── clawhdf5-agent — Memory engine (20.7K lines, 32 modules)
│ ├── clawhdf5-ann — HNSW approximate nearest neighbor │ ├── clawhdf5-ann — HNSW approximate nearest neighbor (default backend)
│ ├── clawhdf5-migrate — SQLite → HDF5 migration │ ├── clawhdf5-migrate — SQLite → HDF5 migration
│ ├── clawhdf5-android — Android JNI bridge │ ├── clawhdf5-android — Android JNI bridge
│ └── clawhdf5-cli — CLI tool │ └── clawhdf5-cli — CLI tool
── Bindings ── Bindings
── clawhdf5-py — Python (PyO3) ── clawhdf5-py — Python (PyO3)
│ └── clawhdf5-napi — Node.js (napi-rs)
└── Tooling
└── clawhdf5-bench — Benchmark suite
``` ```
--- ---
@@ -365,6 +371,7 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
|------|---------|-------------| |------|---------|-------------|
| `agent` | no | Full agent memory layer | | `agent` | no | Full agent memory layer |
| `float16` | **yes** | Half-precision embedding storage (2× compression) | | `float16` | **yes** | Half-precision embedding storage (2× compression) |
| `hnsw` | **yes** | HNSW approximate vector index for `hybrid_search` (via `clawhdf5-ann`); disable for an exact linear scan |
| `parallel` | no | Rayon parallel search | | `parallel` | no | Rayon parallel search |
| `fast-math` | no | BLAS matrix-vector multiply | | `fast-math` | no | BLAS matrix-vector multiply |
| `accelerate` | no | Apple Accelerate / AMX (macOS) | | `accelerate` | no | Apple Accelerate / AMX (macOS) |
@@ -380,7 +387,14 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
| `deflate` | yes | Deflate compression | | `deflate` | yes | Deflate compression |
| `checksum` | yes | Jenkins lookup3 verification | | `checksum` | yes | Jenkins lookup3 verification |
| `provenance` | yes | SHA-256 provenance attributes | | `provenance` | yes | SHA-256 provenance attributes |
| `parallel` | no | Parallel chunk encoding (rayon) | | `fast-deflate` | **yes** | zlib-ng backend for faster deflate |
| `system-zlib-decompress` | **yes** | Use the system zlib for decompression where available |
| `parallel` | no | Parallel chunk encoding + compression (rayon) |
| `fast-checksum` | no | crc32fast-accelerated checksums |
| `lz4` | no | LZ4 block compression filter (id 32004) |
| `zstd` | no | Zstandard compression filter (id 32015) |
| `system-zlib` / `zlib-rs` | no | Alternative zlib backends for deflate |
| `blake3_hash` | no | BLAKE3 content hashing for provenance |
--- ---
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-accel # clawhdf5-accel
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-accel.svg)](https://crates.io/crates/rustyhdf5-accel) [![crates.io](https://img.shields.io/crates/v/clawhdf5-accel.svg)](https://crates.io/crates/clawhdf5-accel)
[![docs.rs](https://docs.rs/rustyhdf5-accel/badge.svg)](https://docs.rs/rustyhdf5-accel) [![docs.rs](https://docs.rs/clawhdf5-accel/badge.svg)](https://docs.rs/clawhdf5-accel)
SIMD-accelerated operations for rustyhdf5. SIMD-accelerated operations for clawhdf5.
## Features ## Features
@@ -15,7 +15,7 @@ SIMD-accelerated operations for rustyhdf5.
## Usage ## Usage
```rust ```rust
use rustyhdf5_accel::checksum::crc32_simd; use clawhdf5_accel::checksum::crc32_simd;
let crc = crc32_simd(&data); let crc = crc32_simd(&data);
``` ```
+7 -7
View File
@@ -1,18 +1,18 @@
# edgehdf5-memory # clawhdf5-agent
[![crates.io](https://img.shields.io/crates/v/edgehdf5-memory.svg)](https://crates.io/crates/edgehdf5-memory) [![crates.io](https://img.shields.io/crates/v/clawhdf5-agent.svg)](https://crates.io/crates/clawhdf5-agent)
[![docs.rs](https://img.shields.io/docsrs/edgehdf5-memory)](https://docs.rs/edgehdf5-memory) [![docs.rs](https://img.shields.io/docsrs/clawhdf5-agent)](https://docs.rs/clawhdf5-agent)
HDF5-backed persistent memory store for on-device AI agents. HDF5-backed persistent memory store for on-device AI agents.
Built on [rustyhdf5](https://crates.io/crates/rustyhdf5), edgehdf5-memory provides a vector-searchable memory backend optimized for edge AI workloads. Store embeddings, text chunks, and metadata in a single HDF5 file with SIMD-accelerated similarity search. Built on [clawhdf5](https://crates.io/crates/clawhdf5), clawhdf5-agent provides a vector-searchable memory backend optimized for edge AI workloads. Store embeddings, text chunks, and metadata in a single HDF5 file with SIMD-accelerated similarity search.
## Features ## Features
- Persistent vector store in HDF5 format - Persistent vector store in HDF5 format
- Cosine similarity and L2 distance search - Cosine similarity and L2 distance search
- SIMD-accelerated via rustyhdf5-accel (AVX2, NEON) - SIMD-accelerated via clawhdf5-accel (AVX2, NEON)
- Optional GPU acceleration via rustyhdf5-gpu - Optional GPU acceleration via clawhdf5-gpu
- Memory-mapped access for large stores - Memory-mapped access for large stores
- f16 storage support for compact embeddings - f16 storage support for compact embeddings
@@ -20,7 +20,7 @@ Built on [rustyhdf5](https://crates.io/crates/rustyhdf5), edgehdf5-memory provid
```toml ```toml
[dependencies] [dependencies]
edgehdf5-memory = "1.93" clawhdf5-agent = "2.1.0"
``` ```
## License ## License
+25 -17
View File
@@ -65,7 +65,7 @@ fn build_memory_group(
let mut group = builder.create_group("memory"); let mut group = builder.create_group("memory");
// chunks: fixed-length string array // chunks: fixed-length string array
write_string_dataset(&mut group, "chunks", &cache.chunks, false); write_string_dataset(&mut group, "chunks", &cache.chunks);
// embeddings: f32 [N x D] // embeddings: f32 [N x D]
let n = cache.embeddings.len() as u64; let n = cache.embeddings.len() as u64;
@@ -101,7 +101,7 @@ fn build_memory_group(
} }
// source_channel: fixed-length string array // source_channel: fixed-length string array
write_string_dataset(&mut group, "source_channel", &cache.source_channels, false); write_string_dataset(&mut group, "source_channel", &cache.source_channels);
// timestamps: f64 array // timestamps: f64 array
group group
@@ -109,11 +109,11 @@ fn build_memory_group(
.with_f64_data(&cache.timestamps) .with_f64_data(&cache.timestamps)
.fill_time(FillTime::Never); .fill_time(FillTime::Never);
// session_ids: fixed-length string array (no compression — chunked compound not yet supported) // session_ids: fixed-length string array (auto-compressed when large)
write_string_dataset(&mut group, "session_ids", &cache.session_ids, false); write_string_dataset(&mut group, "session_ids", &cache.session_ids);
// tags: fixed-length string array (no compression — chunked compound not yet supported) // tags: fixed-length string array (auto-compressed when large)
write_string_dataset(&mut group, "tags", &cache.tags, false); write_string_dataset(&mut group, "tags", &cache.tags);
// tombstones: u8 array — use compact if small // tombstones: u8 array — use compact if small
{ {
@@ -150,7 +150,7 @@ fn build_sessions_group(
let mut group = builder.create_group("sessions"); let mut group = builder.create_group("sessions");
let ids: Vec<String> = sessions.entries.iter().map(|e| e.id.clone()).collect(); let ids: Vec<String> = sessions.entries.iter().map(|e| e.id.clone()).collect();
write_string_dataset(&mut group, "ids", &ids, false); write_string_dataset(&mut group, "ids", &ids);
let start_idxs: Vec<i64> = sessions let start_idxs: Vec<i64> = sessions
.entries .entries
@@ -165,14 +165,14 @@ fn build_sessions_group(
group.create_dataset("end_idxs").with_i64_data(&end_idxs); group.create_dataset("end_idxs").with_i64_data(&end_idxs);
let channels: Vec<String> = sessions.entries.iter().map(|e| e.channel.clone()).collect(); let channels: Vec<String> = sessions.entries.iter().map(|e| e.channel.clone()).collect();
write_string_dataset(&mut group, "channels", &channels, false); write_string_dataset(&mut group, "channels", &channels);
let timestamps: Vec<f64> = sessions.entries.iter().map(|e| e.ts).collect(); let timestamps: Vec<f64> = sessions.entries.iter().map(|e| e.ts).collect();
group group
.create_dataset("timestamps") .create_dataset("timestamps")
.with_f64_data(&timestamps); .with_f64_data(&timestamps);
write_string_dataset(&mut group, "summaries", &sessions.summaries, false); write_string_dataset(&mut group, "summaries", &sessions.summaries);
let finished = group.finish(); let finished = group.finish();
builder.add_group(finished); builder.add_group(finished);
@@ -192,14 +192,14 @@ fn build_knowledge_group(
.with_i64_data(&entity_ids); .with_i64_data(&entity_ids);
let entity_names: Vec<String> = knowledge.entities.iter().map(|e| e.name.clone()).collect(); let entity_names: Vec<String> = knowledge.entities.iter().map(|e| e.name.clone()).collect();
write_string_dataset(&mut group, "entity_names", &entity_names, false); write_string_dataset(&mut group, "entity_names", &entity_names);
let entity_types: Vec<String> = knowledge let entity_types: Vec<String> = knowledge
.entities .entities
.iter() .iter()
.map(|e| e.entity_type.clone()) .map(|e| e.entity_type.clone())
.collect(); .collect();
write_string_dataset(&mut group, "entity_types", &entity_types, false); write_string_dataset(&mut group, "entity_types", &entity_types);
let emb_idxs: Vec<i64> = knowledge.entities.iter().map(|e| e.embedding_idx).collect(); let emb_idxs: Vec<i64> = knowledge.entities.iter().map(|e| e.embedding_idx).collect();
group group
@@ -222,7 +222,7 @@ fn build_knowledge_group(
.iter() .iter()
.map(|r| r.relation.clone()) .map(|r| r.relation.clone())
.collect(); .collect();
write_string_dataset(&mut group, "relation_types", &rel_types, false); write_string_dataset(&mut group, "relation_types", &rel_types);
let rel_weights: Vec<f32> = knowledge.relations.iter().map(|r| r.weight).collect(); let rel_weights: Vec<f32> = knowledge.relations.iter().map(|r| r.weight).collect();
group group
@@ -234,7 +234,7 @@ fn build_knowledge_group(
// Aliases // Aliases
if !knowledge.alias_strings.is_empty() { if !knowledge.alias_strings.is_empty() {
write_string_dataset(&mut group, "alias_strings", &knowledge.alias_strings, false); write_string_dataset(&mut group, "alias_strings", &knowledge.alias_strings);
group group
.create_dataset("alias_entity_ids") .create_dataset("alias_entity_ids")
.with_i64_data(&knowledge.alias_entity_ids); .with_i64_data(&knowledge.alias_entity_ids);
@@ -252,11 +252,15 @@ fn build_knowledge_group(
/// ///
/// When `compress` is true, uses chunked storage with deflate(6) — /// When `compress` is true, uses chunked storage with deflate(6) —
/// NullPad strings have high redundancy and compress very well. /// NullPad strings have high redundancy and compress very well.
/// Payload size (bytes) at or above which a fixed-length string dataset is
/// stored chunked + deflate-compressed. Below this, the chunk B-tree/heap
/// overhead outweighs the savings, so the data is left contiguous.
const STRING_COMPRESS_THRESHOLD: usize = 4096;
fn write_string_dataset( fn write_string_dataset(
group: &mut clawhdf5_format::type_builders::GroupBuilder, group: &mut clawhdf5_format::type_builders::GroupBuilder,
name: &str, name: &str,
strings: &[String], strings: &[String],
compress: bool,
) { ) {
if strings.is_empty() { if strings.is_empty() {
// Empty dataset: use 1-byte string type with no data // Empty dataset: use 1-byte string type with no data
@@ -278,6 +282,7 @@ fn write_string_dataset(
bytes.resize(max_len, 0); bytes.resize(max_len, 0);
raw.extend_from_slice(&bytes); raw.extend_from_slice(&bytes);
} }
let raw_len = raw.len();
let dtype = Datatype::String { let dtype = Datatype::String {
size: max_len as u32, size: max_len as u32,
@@ -288,9 +293,12 @@ fn write_string_dataset(
.create_dataset(name) .create_dataset(name)
.with_compound_data(dtype, raw, strings.len() as u64); .with_compound_data(dtype, raw, strings.len() as u64);
// Deflate compression for string datasets — NullPad has high redundancy // Fixed-length NullPad strings have high redundancy (padding + repeated
if compress && strings.len() > 1 { // content), so deflate pays off once the payload is large enough to absorb
// Chunk size: target ~64KB chunks for string data // the chunking overhead. Fixed-length string datasets are chunkable like
// any other fixed-size datatype.
if strings.len() > 1 && raw_len >= STRING_COMPRESS_THRESHOLD {
// Target ~64KB chunks for string data.
let elem_size = max_len as u64; let elem_size = max_len as u64;
let target_chunk = 64 * 1024; let target_chunk = 64 * 1024;
let rows_per_chunk = (target_chunk / elem_size).max(1).min(strings.len() as u64); let rows_per_chunk = (target_chunk / elem_size).max(1).min(strings.len() as u64);
+4 -4
View File
@@ -1,7 +1,7 @@
# rustyhdf5-ann # clawhdf5-ann
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-ann.svg)](https://crates.io/crates/rustyhdf5-ann) [![crates.io](https://img.shields.io/crates/v/clawhdf5-ann.svg)](https://crates.io/crates/clawhdf5-ann)
[![docs.rs](https://docs.rs/rustyhdf5-ann/badge.svg)](https://docs.rs/rustyhdf5-ann) [![docs.rs](https://docs.rs/clawhdf5-ann/badge.svg)](https://docs.rs/clawhdf5-ann)
HNSW approximate nearest neighbor index stored as HDF5. HNSW approximate nearest neighbor index stored as HDF5.
@@ -14,7 +14,7 @@ HNSW approximate nearest neighbor index stored as HDF5.
## Usage ## Usage
```rust ```rust
use rustyhdf5_ann::HnswIndex; use clawhdf5_ann::HnswIndex;
let index = HnswIndex::from_hdf5("vectors.h5").unwrap(); let index = HnswIndex::from_hdf5("vectors.h5").unwrap();
let neighbors = index.search(&query, 10); let neighbors = index.search(&query, 10);
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-derive # clawhdf5-derive
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-derive.svg)](https://crates.io/crates/rustyhdf5-derive) [![crates.io](https://img.shields.io/crates/v/clawhdf5-derive.svg)](https://crates.io/crates/clawhdf5-derive)
[![docs.rs](https://docs.rs/rustyhdf5-derive/badge.svg)](https://docs.rs/rustyhdf5-derive) [![docs.rs](https://docs.rs/clawhdf5-derive/badge.svg)](https://docs.rs/clawhdf5-derive)
Derive macros for rustyhdf5 HDF5 traits. Derive macros for clawhdf5 HDF5 traits.
## Features ## Features
@@ -13,7 +13,7 @@ Derive macros for rustyhdf5 HDF5 traits.
## Usage ## Usage
```rust ```rust
use rustyhdf5_derive::HDF5Type; use clawhdf5_derive::HDF5Type;
#[derive(HDF5Type)] #[derive(HDF5Type)]
struct Point { struct Point {
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-filters # clawhdf5-filters
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-filters.svg)](https://crates.io/crates/rustyhdf5-filters) [![crates.io](https://img.shields.io/crates/v/clawhdf5-filters.svg)](https://crates.io/crates/clawhdf5-filters)
[![docs.rs](https://docs.rs/rustyhdf5-filters/badge.svg)](https://docs.rs/rustyhdf5-filters) [![docs.rs](https://docs.rs/clawhdf5-filters/badge.svg)](https://docs.rs/clawhdf5-filters)
Filter and compression pipeline for rustyhdf5. Filter and compression pipeline for clawhdf5.
## Features ## Features
@@ -14,7 +14,7 @@ Filter and compression pipeline for rustyhdf5.
## Usage ## Usage
```rust ```rust
use rustyhdf5_filters::{deflate_decode, deflate_encode}; use clawhdf5_filters::{deflate_decode, deflate_encode};
let compressed = deflate_encode(&data, 6).unwrap(); let compressed = deflate_encode(&data, 6).unwrap();
let decompressed = deflate_decode(&compressed).unwrap(); let decompressed = deflate_decode(&compressed).unwrap();
+4 -4
View File
@@ -1,7 +1,7 @@
# rustyhdf5-format # clawhdf5-format
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-format.svg)](https://crates.io/crates/rustyhdf5-format) [![crates.io](https://img.shields.io/crates/v/clawhdf5-format.svg)](https://crates.io/crates/clawhdf5-format)
[![docs.rs](https://docs.rs/rustyhdf5-format/badge.svg)](https://docs.rs/rustyhdf5-format) [![docs.rs](https://docs.rs/clawhdf5-format/badge.svg)](https://docs.rs/clawhdf5-format)
Pure-Rust HDF5 binary format parsing and writing — no C dependencies. Pure-Rust HDF5 binary format parsing and writing — no C dependencies.
@@ -16,7 +16,7 @@ Pure-Rust HDF5 binary format parsing and writing — no C dependencies.
## Usage ## Usage
```rust ```rust
use rustyhdf5_format::Superblock; use clawhdf5_format::Superblock;
let data = std::fs::read("data.h5").unwrap(); let data = std::fs::read("data.h5").unwrap();
let sb = Superblock::from_bytes(&data).unwrap(); let sb = Superblock::from_bytes(&data).unwrap();
+33
View File
@@ -256,6 +256,14 @@ struct CacheInner {
/// Populated once per dataset on first access. /// Populated once per dataset on first access.
index: Option<HashMap<ChunkCoord, ChunkInfo>>, index: Option<HashMap<ChunkCoord, ChunkInfo>>,
/// Address of the dataset (its chunk-index base address) that the cached
/// index, chunk index, layout, and decompressed slots currently belong to.
/// The cache is shared per file across datasets, so every cached-read entry
/// checks this and resets the per-dataset state when the dataset changes —
/// otherwise one dataset's chunk index (with its own rank) would be reused
/// for another, corrupting reads.
index_addr: Option<u64>,
/// LRU cache of decompressed chunk data. /// LRU cache of decompressed chunk data.
slots: Vec<CachedChunk>, slots: Vec<CachedChunk>,
@@ -334,6 +342,7 @@ impl ChunkCache {
Self { Self {
inner: std::sync::Mutex::new(CacheInner { inner: std::sync::Mutex::new(CacheInner {
index: None, index: None,
index_addr: None,
slots: Vec::with_capacity(max_slots.min(64)), slots: Vec::with_capacity(max_slots.min(64)),
current_bytes: 0, current_bytes: 0,
max_bytes, max_bytes,
@@ -349,6 +358,29 @@ impl ChunkCache {
// ----- Index operations ----- // ----- Index operations -----
/// Bind the cache to the dataset at chunk-index address `addr`.
///
/// The cache is shared per file across all of its datasets. If the cache
/// currently holds state for a different dataset, all per-dataset state
/// (chunk index, chunk-index map, layout, and decompressed slots) is
/// dropped so the next access rebuilds it for this dataset. Reading the
/// same dataset again is a no-op, preserving the cache's benefit for
/// repeated/sequential access. Returns `true` if a reset occurred.
pub fn ensure_dataset(&self, addr: u64) -> bool {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
if inner.index_addr == Some(addr) {
return false;
}
inner.index = None;
inner.chunk_index = None;
inner.chunk_layout = None;
inner.slots.clear();
inner.current_bytes = 0;
inner.last_coord = None;
inner.index_addr = Some(addr);
true
}
/// Returns `true` if the chunk index has been built. /// Returns `true` if the chunk index has been built.
pub fn has_index(&self) -> bool { pub fn has_index(&self) -> bool {
self.inner self.inner
@@ -565,6 +597,7 @@ impl ChunkCache {
pub fn clear(&self) { pub fn clear(&self) {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.index = None; inner.index = None;
inner.index_addr = None;
inner.slots.clear(); inner.slots.clear();
inner.current_bytes = 0; inner.current_bytes = 0;
inner.tick = 0; inner.tick = 0;
@@ -593,6 +593,10 @@ pub fn read_chunked_data_cached(
))); )));
} }
// The per-file cache is shared across datasets; bind it to this one so a
// different dataset's chunk index is never reused for this read.
cache.ensure_dataset(addr);
// Populate chunk index on first access // Populate chunk index on first access
if !cache.has_index() { if !cache.has_index() {
let chunks = match (version, chunk_index_type) { let chunks = match (version, chunk_index_type) {
@@ -946,6 +950,10 @@ pub fn read_chunked_data_sweep(
))); )));
} }
// The per-file cache is shared across datasets; bind it to this one so a
// different dataset's chunk index is never reused for this read.
cache.ensure_dataset(addr);
// Populate chunk index on first access // Populate chunk index on first access
if !cache.has_index() { if !cache.has_index() {
let chunks = match (version, chunk_index_type) { let chunks = match (version, chunk_index_type) {
@@ -1169,6 +1177,10 @@ pub fn read_chunked_data_indexed(
))); )));
} }
// The per-file cache is shared across datasets; bind it to this one so a
// different dataset's chunk index is never reused for this read.
cache.ensure_dataset(addr);
// Build chunk index on first access // Build chunk index on first access
if !cache.has_chunk_index() { if !cache.has_chunk_index() {
let chunks = match (version, chunk_index_type) { let chunks = match (version, chunk_index_type) {
+14 -11
View File
@@ -238,11 +238,15 @@ pub fn split_into_chunks(
} }
/// Parallel compression threshold: use rayon when chunk count exceeds this. /// Parallel compression threshold: use rayon when chunk count exceeds this.
#[allow(dead_code)] #[cfg(feature = "parallel")]
const PARALLEL_COMPRESS_THRESHOLD: usize = 4; const PARALLEL_COMPRESS_THRESHOLD: usize = 4;
/// Compress all chunks, using parallel compression when beneficial. /// Compress all chunks, using parallel compression when beneficial.
#[allow(dead_code)] ///
/// With the `parallel` feature and more than [`PARALLEL_COMPRESS_THRESHOLD`]
/// filtered chunks, compression runs across rayon threads; otherwise it is
/// sequential. Output order matches input order, so per-chunk bytes are
/// identical to the sequential path.
fn compress_all_chunks( fn compress_all_chunks(
chunks: &[(Vec<u64>, Vec<u8>)], chunks: &[(Vec<u64>, Vec<u8>)],
pipeline: &Option<FilterPipeline>, pipeline: &Option<FilterPipeline>,
@@ -578,17 +582,16 @@ pub fn build_chunked_data_at_ext(
let num_chunks = chunks.len(); let num_chunks = chunks.len();
let has_filters = pipeline.is_some(); let has_filters = pipeline.is_some();
// Compress each chunk, padding to cache-line boundaries for aligned access // Compress all chunks up front (parallel under the `parallel` feature),
// then lay them out sequentially with cache-line padding for aligned access.
// Compression order matches chunk order, so the on-disk layout is identical
// to the previous per-chunk sequential path.
let compressed_chunks = compress_all_chunks(&chunks, &pipeline, element_size as u32)?;
let mut data_buf = Vec::new(); let mut data_buf = Vec::new();
let mut written_chunks = Vec::with_capacity(num_chunks); let mut written_chunks = Vec::with_capacity(num_chunks);
for (_offsets, chunk_bytes) in &chunks { for ((_offsets, chunk_bytes), compressed) in chunks.iter().zip(compressed_chunks.iter()) {
let compressed = if let Some(pl) = pipeline.as_ref() {
compress_chunk(chunk_bytes, pl, element_size as u32)?
} else {
chunk_bytes.clone()
};
// Pad current position to cache-line boundary // Pad current position to cache-line boundary
let aligned_offset = align_to_cache_line(data_buf.len()); let aligned_offset = align_to_cache_line(data_buf.len());
if aligned_offset > data_buf.len() { if aligned_offset > data_buf.len() {
@@ -599,7 +602,7 @@ pub fn build_chunked_data_at_ext(
let compressed_size = compressed.len() as u64; let compressed_size = compressed.len() as u64;
let raw_size = chunk_bytes.len() as u64; let raw_size = chunk_bytes.len() as u64;
data_buf.extend_from_slice(&compressed); data_buf.extend_from_slice(compressed);
written_chunks.push(WrittenChunk { written_chunks.push(WrittenChunk {
address, address,
+172 -86
View File
@@ -67,74 +67,81 @@ pub enum DataLayout {
}, },
} }
/// Parse VDS mappings from global heap object data. /// Parse VDS mappings from global-heap object data.
/// ///
/// The global heap object for a VDS layout contains a serialized list of /// The global-heap block holding a VDS mapping list is laid out as
/// source mappings. Each mapping has: /// (reverse-engineered and validated against HDF5 2.0):
/// - Virtual selection (serialized dataspace selection, variable length)
/// - Source file name (null-terminated string)
/// - Source dataset name (null-terminated string)
/// - Source selection (serialized dataspace selection, variable length)
/// ///
/// The overall format starts with: /// ```text
/// - version (4 bytes LE) — currently 0 /// version(1) · nused(length_size, LE) · entry[nused] · checksum(4)
/// - entry count (not explicitly stored; parse until data exhausted) /// ```
/// ///
/// This is a best-effort parser that handles common VDS files. The exact /// Each entry is:
/// binary format is not fully specified publicly and may vary by HDF5 version. /// - source file name — a null-terminated string in **block version 0**; in
pub fn parse_vds_mappings(heap_data: &[u8]) -> Result<Vec<VdsMapping>, FormatError> { /// **block version 1** a same-file reference is encoded as a single `0x04`
if heap_data.len() < 4 { /// marker byte (the source file is the virtual file itself) in place of the
/// name;
/// - source dataset name (null-terminated string);
/// - source selection (serialized `H5S` dataspace selection — self-describing
/// in length);
/// - virtual selection (serialized `H5S` dataspace selection).
///
/// The selections are decoded with [`crate::selection::Selection`] purely to
/// learn their byte length so the entry list can be walked; the raw selection
/// bytes are retained on each [`VdsMapping`] for the reader to interpret.
pub fn parse_vds_mappings(
heap_data: &[u8],
length_size: u8,
) -> Result<Vec<VdsMapping>, FormatError> {
use crate::selection::Selection;
let ls = length_size as usize;
if heap_data.len() < 1 + ls {
return Ok(Vec::new()); return Ok(Vec::new());
} }
// VDS global heap object starts with version(4) let version = heap_data[0];
let _version = u32::from_le_bytes([heap_data[0], heap_data[1], heap_data[2], heap_data[3]]); let mut pos = 1;
let mut pos = 4; let nused = read_length(heap_data, pos, length_size)?;
pos += ls;
// `nused` is untrusted; don't pre-allocate from it. Each entry consumes at
// least a few bytes, so the loop is naturally bounded by the heap data and
// a bogus `nused` simply errors out on the first short read.
let mut mappings = Vec::new(); let mut mappings = Vec::new();
// Reads one self-describing selection at `pos`, returning its raw bytes and
// advancing past it — bounds-checked so a corrupt selection can't overrun.
let read_selection = |heap_data: &[u8], pos: &mut usize| -> Result<Vec<u8>, FormatError> {
let rest = heap_data.get(*pos..).ok_or(FormatError::UnexpectedEof {
expected: *pos,
available: heap_data.len(),
})?;
let (_, len) = Selection::decode_serialized(rest)?;
let bytes = rest
.get(..len)
.ok_or(FormatError::UnexpectedEof {
expected: pos.saturating_add(len),
available: heap_data.len(),
})?
.to_vec();
*pos += len;
Ok(bytes)
};
while pos < heap_data.len() { for _ in 0..nused {
// Each entry: virtual_selection_size(4) + virtual_selection(N) + // Source file name (with the version-1 same-file marker handled).
// source_file_name(null-term) + source_dataset_name(null-term) + let source_file = if version >= 1 && heap_data.get(pos) == Some(&0x04) {
// source_selection_size(4) + source_selection(N) pos += 1;
if pos + 4 > heap_data.len() { String::from(".")
break; } else {
} read_null_terminated_string(heap_data, &mut pos)?
};
// Virtual selection // Source dataset name.
let vsel_size = u32::from_le_bytes([
heap_data[pos],
heap_data[pos + 1],
heap_data[pos + 2],
heap_data[pos + 3],
]) as usize;
pos += 4;
if pos + vsel_size > heap_data.len() {
break;
}
let virtual_selection = heap_data[pos..pos + vsel_size].to_vec();
pos += vsel_size;
// Source file name (null-terminated)
let source_file = read_null_terminated_string(heap_data, &mut pos)?;
// Source dataset name (null-terminated)
let source_dataset = read_null_terminated_string(heap_data, &mut pos)?; let source_dataset = read_null_terminated_string(heap_data, &mut pos)?;
// Source selection // Source selection, then virtual selection (both self-describing length).
if pos + 4 > heap_data.len() { let source_selection = read_selection(heap_data, &mut pos)?;
break; let virtual_selection = read_selection(heap_data, &mut pos)?;
}
let ssel_size = u32::from_le_bytes([
heap_data[pos],
heap_data[pos + 1],
heap_data[pos + 2],
heap_data[pos + 3],
]) as usize;
pos += 4;
if pos + ssel_size > heap_data.len() {
break;
}
let source_selection = heap_data[pos..pos + ssel_size].to_vec();
pos += ssel_size;
mappings.push(VdsMapping { mappings.push(VdsMapping {
source_file, source_file,
@@ -235,7 +242,7 @@ impl DataLayout {
index: *global_heap_index as u16, index: *global_heap_index as u16,
}, },
)?; )?;
*mappings = parse_vds_mappings(&obj.data)?; *mappings = parse_vds_mappings(&obj.data, length_size)?;
} }
Ok(()) Ok(())
} }
@@ -250,7 +257,9 @@ impl DataLayout {
match version { match version {
3 => Self::parse_v3(data, layout_class, offset_size, length_size), 3 => Self::parse_v3(data, layout_class, offset_size, length_size),
4 => Self::parse_v4(data, layout_class, offset_size, length_size), // v5 (emitted by HDF5 1.14+/2.0 with `libver=latest`) uses the same
// message structure as v4 — only the version number was bumped.
4 | 5 => Self::parse_v4(data, layout_class, offset_size, length_size),
_ => Err(FormatError::InvalidLayoutVersion(version)), _ => Err(FormatError::InvalidLayoutVersion(version)),
} }
} }
@@ -626,6 +635,30 @@ mod tests {
); );
} }
#[test]
fn v5_chunked_from_hdf5_2_0() {
// Real data layout message from h5py 3.16 / HDF5 2.0 (`libver=latest`)
// for a gzip-compressed 1-D chunked dataset. Version 5 uses the same
// structure as v4 (here: chunked, Fixed Array index). Regression guard
// for reading modern-format chunked datasets.
let bytes: [u8; 17] = [
0x05, 0x02, 0x00, 0x02, 0x01, 0x0a, 0x08, 0x03, 0x0a, 0xef, 0x05, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00,
];
let layout = DataLayout::parse(&bytes, 8, 8).unwrap();
match layout {
DataLayout::Chunked {
chunk_dimensions,
chunk_index_type,
..
} => {
assert_eq!(chunk_dimensions, vec![10, 8]);
assert_eq!(chunk_index_type, Some(3)); // Fixed Array
}
other => panic!("expected Chunked, got {other:?}"),
}
}
#[test] #[test]
fn v4_chunked_single_chunk_no_filters() { fn v4_chunked_single_chunk_no_filters() {
let mut buf = vec![4u8, 2]; // version=4, class=2 let mut buf = vec![4u8, 2]; // version=4, class=2
@@ -678,9 +711,10 @@ mod tests {
#[test] #[test]
fn invalid_version() { fn invalid_version() {
let buf = vec![5u8, 0, 0, 0]; // v3-v5 are supported; v6 is not a real layout message version.
let buf = vec![6u8, 0, 0, 0];
let err = DataLayout::parse(&buf, 8, 8).unwrap_err(); let err = DataLayout::parse(&buf, 8, 8).unwrap_err();
assert_eq!(err, FormatError::InvalidLayoutVersion(5)); assert_eq!(err, FormatError::InvalidLayoutVersion(6));
} }
#[test] #[test]
@@ -747,32 +781,84 @@ mod tests {
} }
#[test] #[test]
fn parse_vds_mappings_basic() { fn parse_vds_mappings_same_file_v1() {
// Build a simple VDS mapping blob // The exact global-heap block written by HDF5 2.0 for a same-file VDS
let mut blob = Vec::new(); // with two sources: src_a -> virtual[0:4], src_b -> virtual[4:8].
blob.extend_from_slice(&0u32.to_le_bytes()); // version=0 let blob = [
0x01u8, // block version 1
0x02, 0, 0, 0, 0, 0, 0, 0, // nused = 2 (length_size = 8)
// entry 0
0x04, // same-file marker (replaces file name)
0x73, 0x72, 0x63, 0x5f, 0x61, 0x00, // "src_a\0"
0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // source sel = ALL
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, // virtual sel: HYPER v3
0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x04, 0x00, // start0 stride1 count1 block4
// entry 1
0x04, 0x73, 0x72, 0x63, 0x5f, 0x62, 0x00, // "src_b\0"
0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // source sel = ALL
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, // virtual sel: HYPER v3
0x04, 0x00, 0x01, 0x00, 0x01, 0x00, 0x04, 0x00, // start4 stride1 count1 block4
0x68, 0xf0, 0x3e, 0xe4, // checksum (ignored)
];
let mappings = parse_vds_mappings(&blob, 8).unwrap();
assert_eq!(mappings.len(), 2);
assert_eq!(mappings[0].source_file, ".");
assert_eq!(mappings[0].source_dataset, "src_a");
assert_eq!(mappings[1].source_file, ".");
assert_eq!(mappings[1].source_dataset, "src_b");
// Virtual selection (8 bytes of dummy data) // Virtual selections decode to [0:4] and [4:8].
let vsel = vec![1, 2, 3, 4, 5, 6, 7, 8]; use crate::selection::Selection;
blob.extend_from_slice(&(vsel.len() as u32).to_le_bytes()); let (v0, _) = Selection::decode_serialized(&mappings[0].virtual_selection).unwrap();
blob.extend_from_slice(&vsel); let (v1, _) = Selection::decode_serialized(&mappings[1].virtual_selection).unwrap();
assert_eq!(v0.iter_linear_1d(8).unwrap(), vec![0, 1, 2, 3]);
assert_eq!(v1.iter_linear_1d(8).unwrap(), vec![4, 5, 6, 7]);
}
// Source file name #[test]
blob.extend_from_slice(b"source.h5\0"); fn parse_vds_mappings_external_v0() {
// Block version 0 with an explicit (external) source file name.
// Source dataset name let blob = [
blob.extend_from_slice(b"/data\0"); 0x00u8, // block version 0
0x01, 0, 0, 0, 0, 0, 0, 0, // nused = 1
// Source selection (4 bytes) 0x73, 0x72, 0x63, 0x5f, 0x65, 0x78, 0x74, 0x2e, 0x68, 0x35, 0x00, // "src_ext.h5\0"
let ssel = vec![10, 20, 30, 40]; 0x64, 0x61, 0x74, 0x61, 0x00, // "data\0"
blob.extend_from_slice(&(ssel.len() as u32).to_le_bytes()); 0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // source sel = ALL
blob.extend_from_slice(&ssel); 0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // virtual sel = ALL
];
let mappings = parse_vds_mappings(&blob).unwrap(); let mappings = parse_vds_mappings(&blob, 8).unwrap();
assert_eq!(mappings.len(), 1); assert_eq!(mappings.len(), 1);
assert_eq!(mappings[0].source_file, "source.h5"); assert_eq!(mappings[0].source_file, "src_ext.h5");
assert_eq!(mappings[0].source_dataset, "/data"); assert_eq!(mappings[0].source_dataset, "data");
assert_eq!(mappings[0].virtual_selection, vsel); }
assert_eq!(mappings[0].source_selection, ssel);
#[test]
fn parse_vds_mappings_huge_nused_does_not_oom_or_panic() {
// nused = u64::MAX with no entry data: must error, not pre-allocate or
// overrun.
let mut blob = vec![0x01u8];
blob.extend_from_slice(&u64::MAX.to_le_bytes());
assert!(parse_vds_mappings(&blob, 8).is_err());
}
#[test]
fn parse_vds_mappings_truncated_selection_does_not_overrun() {
// One entry whose source selection (ALL) is truncated to 8 of 16 bytes.
let blob = [
0x01u8, // version 1
0x01, 0, 0, 0, 0, 0, 0, 0, // nused = 1
0x04, // same-file marker
0x78, 0x00, // "x\0"
0x03, 0, 0, 0, 0x01, 0, 0, 0, // ALL header, truncated (8 of 16 bytes)
];
assert!(parse_vds_mappings(&blob, 8).is_err());
}
#[test]
fn parse_vds_mappings_empty_is_ok_empty() {
assert!(parse_vds_mappings(&[], 8).unwrap().is_empty());
// Header present, nused = 0.
let blob = [0x01u8, 0, 0, 0, 0, 0, 0, 0, 0];
assert!(parse_vds_mappings(&blob, 8).unwrap().is_empty());
} }
} }
+496 -34
View File
@@ -73,6 +73,16 @@ pub fn read_raw_data(
read_raw_data_full(file_data, layout, dataspace, datatype, None, 8, 8) read_raw_data_full(file_data, layout, dataspace, datatype, None, 8, 8)
} }
/// Resolves a Virtual Dataset source **file name** (as stored in the mapping,
/// e.g. `"ext_src.h5"`) to that file's raw bytes.
///
/// The pure-byte read API has no filesystem of its own, so external-file VDS
/// sources are read through a caller-supplied resolver. The std file API wires
/// one that reads relative to the virtual file's directory; callers can supply
/// their own (e.g. an in-memory map) in `no_std` builds. Returning `None` means
/// the source file is unavailable and the mapping is skipped.
pub type VdsSourceResolver<'a> = dyn Fn(&str) -> Option<Vec<u8>> + 'a;
/// Read raw bytes with full parameters including filter pipeline and sizes. /// Read raw bytes with full parameters including filter pipeline and sizes.
pub fn read_raw_data_full( pub fn read_raw_data_full(
file_data: &[u8], file_data: &[u8],
@@ -82,6 +92,40 @@ pub fn read_raw_data_full(
pipeline: Option<&FilterPipeline>, pipeline: Option<&FilterPipeline>,
offset_size: u8, offset_size: u8,
length_size: u8, length_size: u8,
) -> Result<Vec<u8>, FormatError> {
read_raw_data_full_impl(
file_data, layout, dataspace, datatype, pipeline, offset_size, length_size, None,
)
}
/// Like [`read_raw_data_full`], but with a resolver for external-file Virtual
/// Dataset sources. For non-virtual layouts the resolver is ignored.
#[allow(clippy::too_many_arguments)]
pub fn read_raw_data_full_with_resolver(
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
datatype: &Datatype,
pipeline: Option<&FilterPipeline>,
offset_size: u8,
length_size: u8,
resolver: Option<&VdsSourceResolver>,
) -> Result<Vec<u8>, FormatError> {
read_raw_data_full_impl(
file_data, layout, dataspace, datatype, pipeline, offset_size, length_size, resolver,
)
}
#[allow(clippy::too_many_arguments)]
fn read_raw_data_full_impl(
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
datatype: &Datatype,
pipeline: Option<&FilterPipeline>,
offset_size: u8,
length_size: u8,
resolver: Option<&VdsSourceResolver>,
) -> Result<Vec<u8>, FormatError> { ) -> Result<Vec<u8>, FormatError> {
let num_elements = dataspace.num_elements() as usize; let num_elements = dataspace.num_elements() as usize;
let elem_size = datatype.type_size() as usize; let elem_size = datatype.type_size() as usize;
@@ -128,7 +172,20 @@ pub fn read_raw_data_full(
offset_size, offset_size,
length_size, length_size,
), ),
DataLayout::Virtual { .. } => Err(FormatError::UnsupportedVersion(0)), DataLayout::Virtual {
global_heap_address,
global_heap_index,
..
} => read_virtual_data(
file_data,
*global_heap_address,
*global_heap_index,
dataspace,
datatype,
offset_size,
length_size,
resolver,
),
} }
} }
@@ -355,8 +412,170 @@ pub fn read_raw_data_selection(
)?; )?;
extract_selection_from_buffer(&full_data, dims, elem_size, selection) extract_selection_from_buffer(&full_data, dims, elem_size, selection)
} }
DataLayout::Virtual { .. } => Err(FormatError::UnsupportedVersion(0)), DataLayout::Virtual { .. } => {
// Assemble the full virtual dataset, then apply the read selection.
let full_data = read_raw_data_full(
file_data,
layout,
dataspace,
datatype,
pipeline,
offset_size,
length_size,
)?;
extract_selection_from_buffer(&full_data, dims, elem_size, selection)
} }
}
}
/// Assemble a **Virtual Dataset (VDS)** from its source mappings.
///
/// Supports virtual datasets of any rank. Same-file sources are read directly;
/// **external-file** sources are read through the caller-supplied `resolver`,
/// which maps a stored source file name to that file's bytes. Each mapping's
/// selected source elements are scattered into the virtual buffer at the
/// positions given by the virtual selection (both enumerated in row-major
/// order, as HDF5 pairs them). Unmapped regions are left at the zero fill value.
///
/// A mapping whose external source file the resolver cannot supply (`None`) is
/// skipped, leaving its region at fill — matching HDF5's tolerance of missing
/// sources. An external source with no resolver at all is a hard error.
#[allow(clippy::too_many_arguments)]
fn read_virtual_data(
file_data: &[u8],
global_heap_address: Option<u64>,
global_heap_index: u32,
dataspace: &Dataspace,
datatype: &Datatype,
offset_size: u8,
length_size: u8,
resolver: Option<&VdsSourceResolver>,
) -> Result<Vec<u8>, FormatError> {
use crate::data_layout::parse_vds_mappings;
use crate::global_heap::GlobalHeapCollection;
use crate::selection::Selection;
let elem_size = datatype.type_size() as usize;
let total_elems = dataspace.num_elements() as usize;
let mut out = vec![0u8; total_elems.saturating_mul(elem_size)];
let virtual_dims = &dataspace.dimensions;
let addr = global_heap_address.ok_or_else(|| {
FormatError::ChunkedReadError("virtual dataset has no mapping global heap".into())
})?;
let coll = GlobalHeapCollection::parse(file_data, addr as usize, length_size)?;
let obj = coll
.get_object(global_heap_index as u16)
.ok_or(FormatError::GlobalHeapObjectNotFound {
collection_address: addr,
index: global_heap_index as u16,
})?;
let mappings = parse_vds_mappings(&obj.data, length_size)?;
for m in &mappings {
let same_file = m.source_file.is_empty() || m.source_file == ".";
// Resolve the bytes of the file holding this source dataset.
let external;
let src_file_data: &[u8] = if same_file {
file_data
} else {
let r = resolver.ok_or_else(|| {
FormatError::ChunkedReadError(
"external-file virtual dataset sources require a file resolver".into(),
)
})?;
match r(&m.source_file) {
Some(bytes) => {
external = bytes;
&external
}
// Source file unavailable: leave this region at fill value.
None => continue,
}
};
let (vsel, _) = Selection::decode_serialized(&m.virtual_selection)?;
let (ssel, _) = Selection::decode_serialized(&m.source_selection)?;
let (src_raw, src_dims) =
read_named_dataset_raw(src_file_data, &m.source_dataset, offset_size, length_size)?;
let vidx = vsel.iter_linear(virtual_dims)?;
let sidx = ssel.iter_linear(&src_dims)?;
if vidx.len() != sidx.len() {
return Err(FormatError::ChunkedReadError(
"virtual/source selection element counts differ".into(),
));
}
for (&v, &s) in vidx.iter().zip(sidx.iter()) {
let (vo, so) = (v as usize * elem_size, s as usize * elem_size);
if vo + elem_size > out.len() || so + elem_size > src_raw.len() {
return Err(FormatError::ChunkedReadError(
"virtual dataset selection out of bounds".into(),
));
}
out[vo..vo + elem_size].copy_from_slice(&src_raw[so..so + elem_size]);
}
}
Ok(out)
}
/// Read a named dataset's raw (decoded) bytes and its dimensions, navigating
/// from the superblock. Used to pull VDS source datasets out of the same file.
fn read_named_dataset_raw(
file_data: &[u8],
path: &str,
_offset_size: u8,
_length_size: u8,
) -> Result<(Vec<u8>, Vec<u64>), FormatError> {
use crate::filter_pipeline::FilterPipeline;
use crate::group_v2::resolve_path_any;
use crate::message_type::MessageType;
use crate::object_header::ObjectHeader;
use crate::signature::find_signature;
use crate::superblock::Superblock;
let sig = find_signature(file_data)?;
let sb = Superblock::parse(file_data, sig)?;
let addr = resolve_path_any(file_data, &sb, path)?;
let hdr = ObjectHeader::parse(file_data, addr as usize, sb.offset_size, sb.length_size)?;
let find = |t: MessageType| hdr.messages.iter().find(|m| m.msg_type == t);
let ds_msg = find(MessageType::Dataspace)
.ok_or_else(|| FormatError::ChunkedReadError("VDS source has no dataspace".into()))?;
let dataspace = Dataspace::parse(&ds_msg.data, sb.length_size)?;
let dt_msg = find(MessageType::Datatype)
.ok_or_else(|| FormatError::ChunkedReadError("VDS source has no datatype".into()))?;
let (datatype, _) = Datatype::parse(&dt_msg.data)?;
let dl_msg = find(MessageType::DataLayout)
.ok_or_else(|| FormatError::ChunkedReadError("VDS source has no data layout".into()))?;
let layout = DataLayout::parse(&dl_msg.data, sb.offset_size, sb.length_size)?;
// A virtual dataset whose source is itself another virtual dataset could
// form a cycle (A -> B -> A) and recurse into a stack overflow. Nested
// virtual sources are exotic and unsupported, so stop here cleanly.
if matches!(layout, DataLayout::Virtual { .. }) {
return Err(FormatError::ChunkedReadError(
"virtual dataset source is itself virtual (unsupported)".into(),
));
}
let pipeline = find(MessageType::FilterPipeline)
.map(|m| FilterPipeline::parse(&m.data))
.transpose()?;
let raw = read_raw_data_full(
file_data,
&layout,
&dataspace,
&datatype,
pipeline.as_ref(),
sb.offset_size,
sb.length_size,
)?;
Ok((raw, dataspace.dimensions.clone()))
} }
/// Extract selected elements from a full dataset buffer. /// Extract selected elements from a full dataset buffer.
@@ -618,6 +837,11 @@ fn get_size(dt: &Datatype) -> usize {
/// Convert raw bytes to `f64` values. /// Convert raw bytes to `f64` values.
pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> { pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> {
// Array datatypes (e.g. an array-typed compound member) are read as a flat
// sequence of their base elements.
if let Datatype::Array { base_type, .. } = datatype {
return read_as_f64(raw, base_type);
}
ensure_numeric(datatype, "FloatingPoint or FixedPoint")?; ensure_numeric(datatype, "FloatingPoint or FixedPoint")?;
let elem_size = get_size(datatype); let elem_size = get_size(datatype);
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) { if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
@@ -671,19 +895,27 @@ fn convert_to_f64(
Ok(v as f64) Ok(v as f64)
} }
8 => Ok(read_f64_bytes(bytes, order)), 8 => Ok(read_f64_bytes(bytes, order)),
2 => Ok(read_f16_bytes(bytes, order) as f64),
_ => Err(FormatError::DataSizeMismatch { _ => Err(FormatError::DataSizeMismatch {
expected: 8, expected: 8,
actual: *size as usize, actual: *size as usize,
}), }),
}, },
Datatype::FixedPoint { size, signed, .. } => { Datatype::FixedPoint {
if *signed { size,
let v = read_signed_int(bytes, *size as usize, order); signed,
Ok(v as f64) bit_offset,
bit_precision,
..
} => {
let full = read_unsigned_int(bytes, *size as usize, order);
let (off, prec) = effective_bits(*size as usize, *bit_offset, *bit_precision);
let v = if *signed {
extract_signed(full, off, prec) as f64
} else { } else {
let v = read_unsigned_int(bytes, *size as usize, order); extract_unsigned(full, off, prec) as f64
Ok(v as f64) };
} Ok(v)
} }
_ => Err(FormatError::TypeMismatch { _ => Err(FormatError::TypeMismatch {
expected: "numeric", expected: "numeric",
@@ -694,6 +926,9 @@ fn convert_to_f64(
/// Convert raw bytes to `i64` values. /// Convert raw bytes to `i64` values.
pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatError> { pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatError> {
if let Datatype::Array { base_type, .. } = datatype {
return read_as_i64(raw, base_type);
}
ensure_numeric(datatype, "FixedPoint (signed)")?; ensure_numeric(datatype, "FixedPoint (signed)")?;
let elem_size = get_size(datatype); let elem_size = get_size(datatype);
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) { if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
@@ -707,6 +942,7 @@ pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatEr
// Fast path: native LE i64 — single bulk memcpy // Fast path: native LE i64 — single bulk memcpy
#[cfg(target_endian = "little")] #[cfg(target_endian = "little")]
if elem_size == 8 if elem_size == 8
&& is_full_width(datatype)
&& matches!( && matches!(
datatype, datatype,
Datatype::FixedPoint { Datatype::FixedPoint {
@@ -725,17 +961,21 @@ pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatEr
} }
let order = get_byte_order(datatype); let order = get_byte_order(datatype);
let (off, prec) = fixed_bits(datatype);
let mut result = Vec::with_capacity(count); let mut result = Vec::with_capacity(count);
for i in 0..count { for i in 0..count {
let chunk = &raw[i * elem_size..(i + 1) * elem_size]; let chunk = &raw[i * elem_size..(i + 1) * elem_size];
let v = read_signed_int(chunk, elem_size, &order); let full = read_unsigned_int(chunk, elem_size, &order);
result.push(v); result.push(extract_signed(full, off, prec));
} }
Ok(result) Ok(result)
} }
/// Convert raw bytes to `u64` values. /// Convert raw bytes to `u64` values.
pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatError> { pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatError> {
if let Datatype::Array { base_type, .. } = datatype {
return read_as_u64(raw, base_type);
}
ensure_numeric(datatype, "FixedPoint (unsigned)")?; ensure_numeric(datatype, "FixedPoint (unsigned)")?;
let elem_size = get_size(datatype); let elem_size = get_size(datatype);
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) { if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
@@ -746,17 +986,21 @@ pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatEr
} }
let count = raw.len() / elem_size; let count = raw.len() / elem_size;
let order = get_byte_order(datatype); let order = get_byte_order(datatype);
let (off, prec) = fixed_bits(datatype);
let mut result = Vec::with_capacity(count); let mut result = Vec::with_capacity(count);
for i in 0..count { for i in 0..count {
let chunk = &raw[i * elem_size..(i + 1) * elem_size]; let chunk = &raw[i * elem_size..(i + 1) * elem_size];
let v = read_unsigned_int(chunk, elem_size, &order); let full = read_unsigned_int(chunk, elem_size, &order);
result.push(v); result.push(extract_unsigned(full, off, prec));
} }
Ok(result) Ok(result)
} }
/// Convert raw bytes to `f32` values. /// Convert raw bytes to `f32` values.
pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatError> { pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatError> {
if let Datatype::Array { base_type, .. } = datatype {
return read_as_f32(raw, base_type);
}
ensure_numeric(datatype, "FloatingPoint")?; ensure_numeric(datatype, "FloatingPoint")?;
let elem_size = get_size(datatype); let elem_size = get_size(datatype);
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) { if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
@@ -796,17 +1040,30 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
Datatype::FloatingPoint { size: 8, .. } => { Datatype::FloatingPoint { size: 8, .. } => {
result.push(read_f64_bytes(chunk, &order) as f32); result.push(read_f64_bytes(chunk, &order) as f32);
} }
Datatype::FloatingPoint { size: 2, .. } => {
result.push(read_f16_bytes(chunk, &order));
}
Datatype::FixedPoint { Datatype::FixedPoint {
signed: true, size, .. signed: true,
size,
bit_offset,
bit_precision,
..
} => { } => {
result.push(read_signed_int(chunk, *size as usize, &order) as f32); let full = read_unsigned_int(chunk, *size as usize, &order);
let (off, prec) = effective_bits(*size as usize, *bit_offset, *bit_precision);
result.push(extract_signed(full, off, prec) as f32);
} }
Datatype::FixedPoint { Datatype::FixedPoint {
signed: false, signed: false,
size, size,
bit_offset,
bit_precision,
.. ..
} => { } => {
result.push(read_unsigned_int(chunk, *size as usize, &order) as f32); let full = read_unsigned_int(chunk, *size as usize, &order);
let (off, prec) = effective_bits(*size as usize, *bit_offset, *bit_precision);
result.push(extract_unsigned(full, off, prec) as f32);
} }
_ => { _ => {
return Err(FormatError::TypeMismatch { return Err(FormatError::TypeMismatch {
@@ -821,6 +1078,9 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
/// Convert raw bytes to `i32` values. /// Convert raw bytes to `i32` values.
pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatError> { pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatError> {
if let Datatype::Array { base_type, .. } = datatype {
return read_as_i32(raw, base_type);
}
ensure_numeric(datatype, "FixedPoint")?; ensure_numeric(datatype, "FixedPoint")?;
let elem_size = get_size(datatype); let elem_size = get_size(datatype);
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) { if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
@@ -834,6 +1094,7 @@ pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatEr
// Fast path: native LE i32 — single bulk memcpy // Fast path: native LE i32 — single bulk memcpy
#[cfg(target_endian = "little")] #[cfg(target_endian = "little")]
if elem_size == 4 if elem_size == 4
&& is_full_width(datatype)
&& matches!( && matches!(
datatype, datatype,
Datatype::FixedPoint { Datatype::FixedPoint {
@@ -851,11 +1112,12 @@ pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatEr
} }
let order = get_byte_order(datatype); let order = get_byte_order(datatype);
let (off, prec) = fixed_bits(datatype);
let mut result = Vec::with_capacity(count); let mut result = Vec::with_capacity(count);
for i in 0..count { for i in 0..count {
let chunk = &raw[i * elem_size..(i + 1) * elem_size]; let chunk = &raw[i * elem_size..(i + 1) * elem_size];
let v = read_signed_int(chunk, elem_size, &order); let full = read_unsigned_int(chunk, elem_size, &order);
result.push(v as i32); result.push(extract_signed(full, off, prec) as i32);
} }
Ok(result) Ok(result)
} }
@@ -1232,6 +1494,53 @@ fn read_f64_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f64 {
f64::from_le_bytes(buf) f64::from_le_bytes(buf)
} }
/// Decode an IEEE-754 half-precision (binary16) value to `f32`. Pure integer
/// bit manipulation (no_std-safe, no `powi`/`libm`).
fn read_f16_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f32 {
let mut buf = [0u8; 2];
let len = bytes.len().min(2);
match order {
DatatypeByteOrder::BigEndian => {
for i in 0..len {
buf[i] = bytes[len - 1 - i];
}
}
_ => buf[..len].copy_from_slice(&bytes[..len]),
}
f16_bits_to_f32(u16::from_le_bytes(buf))
}
/// Convert the bit pattern of an IEEE-754 half (binary16) to an `f32`.
fn f16_bits_to_f32(h: u16) -> f32 {
let h = h as u32;
let sign = (h & 0x8000) << 16;
let exp = (h >> 10) & 0x1f;
let mant = h & 0x3ff;
let bits = if exp == 0 {
if mant == 0 {
sign // signed zero
} else {
// Subnormal: normalize into an f32 normal.
let mut e: i32 = -1;
let mut m = mant;
loop {
e += 1;
m <<= 1;
if m & 0x400 != 0 {
break;
}
}
let m = m & 0x3ff;
sign | (((127 - 15 - e) as u32) << 23) | (m << 13)
}
} else if exp == 0x1f {
sign | 0x7f80_0000 | (mant << 13) // inf / NaN
} else {
sign | ((exp + (127 - 15)) << 23) | (mant << 13)
};
f32::from_bits(bits)
}
fn read_f32_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f32 { fn read_f32_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f32 {
let mut buf = [0u8; 4]; let mut buf = [0u8; 4];
let len = bytes.len().min(4); let len = bytes.len().min(4);
@@ -1248,6 +1557,68 @@ fn read_f32_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f32 {
f32::from_le_bytes(buf) f32::from_le_bytes(buf)
} }
/// Effective (bit offset, bit precision) for a fixed-point field, defaulting a
/// zero precision to the full storage width.
fn effective_bits(size: usize, bit_offset: u16, bit_precision: u16) -> (u32, u32) {
let prec = if bit_precision == 0 {
(size * 8) as u32
} else {
bit_precision as u32
};
(bit_offset as u32, prec)
}
/// `(bit_offset, bit_precision)` for a fixed-point datatype, full width for
/// other types.
fn fixed_bits(datatype: &Datatype) -> (u32, u32) {
match datatype {
Datatype::FixedPoint {
size,
bit_offset,
bit_precision,
..
} => effective_bits(*size as usize, *bit_offset, *bit_precision),
_ => (0, 0),
}
}
/// Whether a datatype occupies its full storage width (bit offset 0, precision
/// == size·8), in which case the bulk-copy fast read paths apply. Non
/// fixed-point types are treated as full width.
fn is_full_width(datatype: &Datatype) -> bool {
match datatype {
Datatype::FixedPoint {
size,
bit_offset,
bit_precision,
..
} => *bit_offset == 0 && *bit_precision as u32 == *size * 8,
_ => true,
}
}
/// Extract the `precision`-bit field at `offset` from a full-width integer read
/// and sign-extend it. Full-width fields read as an ordinary signed integer;
/// reduced-precision fields sign-extend from the field's top bit (HDF5 stores
/// reduced-precision values zero-filled, so the sign lives in the precision
/// field, not the storage word).
fn extract_signed(full: u64, offset: u32, precision: u32) -> i64 {
if precision == 0 || precision >= 64 {
return full as i64;
}
let field = (full >> offset) & ((1u64 << precision) - 1);
let shift = 64 - precision;
((field << shift) as i64) >> shift
}
/// Extract the `precision`-bit field at `offset` from a full-width integer read.
fn extract_unsigned(full: u64, offset: u32, precision: u32) -> u64 {
if precision == 0 || precision >= 64 {
return full;
}
(full >> offset) & ((1u64 << precision) - 1)
}
fn read_unsigned_int(bytes: &[u8], size: usize, order: &DatatypeByteOrder) -> u64 { fn read_unsigned_int(bytes: &[u8], size: usize, order: &DatatypeByteOrder) -> u64 {
let buf = reorder_bytes(bytes, order); let buf = reorder_bytes(bytes, order);
match size { match size {
@@ -1266,22 +1637,6 @@ fn read_unsigned_int(bytes: &[u8], size: usize, order: &DatatypeByteOrder) -> u6
} }
} }
fn read_signed_int(bytes: &[u8], size: usize, order: &DatatypeByteOrder) -> i64 {
let buf = reorder_bytes(bytes, order);
match size {
1 => buf[0] as i8 as i64,
2 => i16::from_le_bytes([buf[0], buf[1]]) as i64,
4 => i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) as i64,
8 => i64::from_le_bytes(buf),
_ => {
let u = read_unsigned_int(bytes, size, order);
// Sign extend
let shift = 64 - (size * 8);
((u as i64) << shift) >> shift
}
}
}
// --- Type conversion cost analysis --- // --- Type conversion cost analysis ---
/// Cost classification for type conversions. /// Cost classification for type conversions.
@@ -1362,6 +1717,113 @@ mod tests {
use crate::dataspace::{Dataspace, DataspaceType}; use crate::dataspace::{Dataspace, DataspaceType};
use crate::datatype::{CharacterSet, StringPadding}; use crate::datatype::{CharacterSet, StringPadding};
fn f16_datatype() -> Datatype {
Datatype::FloatingPoint {
size: 2,
byte_order: DatatypeByteOrder::LittleEndian,
bit_offset: 0,
bit_precision: 16,
exponent_location: 10,
exponent_size: 5,
mantissa_location: 0,
mantissa_size: 10,
exponent_bias: 15,
}
}
// IEEE-754 half bit patterns for known values.
fn f16_bits(v: f32) -> u16 {
// Encode a few exact values used by the test.
match v {
x if x == 0.0 => 0x0000,
x if x == 1.0 => 0x3c00,
x if x == -2.0 => 0xc000,
x if x == 0.5 => 0x3800,
x if x == 65504.0 => 0x7bff, // f16 max
_ => panic!("unsupported test value {v}"),
}
}
#[test]
fn read_f16_as_f32_and_f64() {
let values = [0.0f32, 1.0, -2.0, 0.5, 65504.0];
let raw: Vec<u8> = values
.iter()
.flat_map(|&v| f16_bits(v).to_le_bytes())
.collect();
let dt = f16_datatype();
let got32 = read_as_f32(&raw, &dt).unwrap();
assert_eq!(got32, values);
let got64 = read_as_f64(&raw, &dt).unwrap();
let expect64: Vec<f64> = values.iter().map(|&v| v as f64).collect();
assert_eq!(got64, expect64);
}
fn reduced_int(signed: bool, precision: u16) -> Datatype {
Datatype::FixedPoint {
size: 4,
byte_order: DatatypeByteOrder::LittleEndian,
signed,
bit_offset: 0,
bit_precision: precision,
}
}
#[test]
fn reduced_precision_signed_sign_extends() {
// 16-bit-precision signed values stored zero-filled (HDF5's canonical
// layout, e.g. after N-Bit): the reader must sign-extend from bit 15.
let dt = reduced_int(true, 16);
// [-1, 100, -50, -32768] as 0x0000ffff / 0x00000064 / 0x0000ffce / 0x00008000
let raw: Vec<u8> = vec![
0xff, 0xff, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xce, 0xff, 0x00, 0x00, 0x00, 0x80,
0x00, 0x00,
];
assert_eq!(read_as_i32(&raw, &dt).unwrap(), vec![-1, 100, -50, -32768]);
assert_eq!(read_as_i64(&raw, &dt).unwrap(), vec![-1, 100, -50, -32768]);
}
#[test]
fn reduced_precision_unsigned_masks() {
// 12-bit-precision unsigned: high bits must read as zero, not sign.
let dt = reduced_int(false, 12);
// [4095, 1, 2048] as 0x00000fff / 0x00000001 / 0x00000800
let raw: Vec<u8> = vec![
0xff, 0x0f, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00,
];
assert_eq!(read_as_u64(&raw, &dt).unwrap(), vec![4095, 1, 2048]);
}
#[test]
fn full_width_signed_unchanged() {
// Regression: full-width 32-bit signed must be unaffected.
let dt = reduced_int(true, 32);
let raw: Vec<u8> = vec![0xff, 0xff, 0xff, 0xff, 0x2a, 0x00, 0x00, 0x00];
assert_eq!(read_as_i32(&raw, &dt).unwrap(), vec![-1, 42]);
}
#[test]
fn array_datatype_reads_flat_base_elements() {
// An array-typed (e.g. compound member) datatype reads as a flat
// sequence of its base elements, applying base-type precision rules.
let arr = Datatype::Array {
base_type: Box::new(reduced_int(true, 16)),
dimensions: vec![2],
};
// [-1, 100, 1000, -32768] stored zero-filled at 16-bit precision.
let raw: Vec<u8> = vec![
0xff, 0xff, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xe8, 0x03, 0x00, 0x00, 0x00, 0x80,
0x00, 0x00,
];
assert_eq!(read_as_i32(&raw, &arr).unwrap(), vec![-1, 100, 1000, -32768]);
// Nested array-of-array unwraps recursively.
let nested = Datatype::Array {
base_type: Box::new(arr),
dimensions: vec![2],
};
assert_eq!(read_as_i32(&raw, &nested).unwrap(), vec![-1, 100, 1000, -32768]);
}
fn make_f64_le_type() -> Datatype { fn make_f64_le_type() -> Datatype {
Datatype::FloatingPoint { Datatype::FloatingPoint {
size: 8, size: 8,
+77 -2
View File
@@ -348,7 +348,10 @@ impl Datatype {
let num_members = (bf0 as u16) | ((bf1 as u16) << 8); let num_members = (bf0 as u16) | ((bf1 as u16) << 8);
let mut members = Vec::with_capacity(num_members as usize); let mut members = Vec::with_capacity(num_members as usize);
if version == 3 || version == 4 { if (3..=5).contains(&version) {
// v3, v4 and v5 share the compact member encoding (name,
// variable-width offset, member datatype). HDF5 1.14+/2.0
// with `libver=latest` emits v5 compound types.
let ob = offset_bytes_for_size(size); let ob = offset_bytes_for_size(size);
for _ in 0..num_members { for _ in 0..num_members {
let (name, name_len) = read_null_terminated_string(data, pos)?; let (name, name_len) = read_null_terminated_string(data, pos)?;
@@ -500,7 +503,9 @@ impl Datatype {
}, },
pos, pos,
)) ))
} else if version == 3 { } else if (3..=5).contains(&version) {
// v3, v4 and v5 share the array encoding (ndims, dims, base
// type); HDF5 1.14+/2.0 with `libver=latest` emits v5.
ensure_len(data, pos, 1)?; ensure_len(data, pos, 1)?;
let ndims = data[pos] as usize; let ndims = data[pos] as usize;
pos += 1; pos += 1;
@@ -1007,6 +1012,76 @@ mod tests {
} }
} }
#[test]
fn test_compound_v5_from_hdf5_2_0() {
// Real datatype message bytes emitted by h5py 3.16 / HDF5 2.0 with
// `libver=latest` for a compound dtype [('x','f8'),('y','f8'),('id','i4')].
// The wrapper is datatype version 5; members reuse the v3 compact
// encoding. Regression guard for reading modern-format compound types.
let bytes: [u8; 70] = [
0x56, 0x03, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x11, 0x20, 0x3f,
0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x34, 0x0b, 0x00, 0x34, 0xff,
0x03, 0x00, 0x00, 0x79, 0x00, 0x08, 0x11, 0x20, 0x3f, 0x00, 0x08, 0x00, 0x00, 0x00,
0x00, 0x00, 0x40, 0x00, 0x34, 0x0b, 0x00, 0x34, 0xff, 0x03, 0x00, 0x00, 0x69, 0x64,
0x00, 0x10, 0x10, 0x08, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00,
];
let (dt, _) = Datatype::parse(&bytes).unwrap();
match dt {
Datatype::Compound { size, members } => {
assert_eq!(size, 20);
assert_eq!(members.len(), 3);
assert_eq!(
(members[0].name.as_str(), members[0].byte_offset),
("x", 0)
);
assert_eq!(
(members[1].name.as_str(), members[1].byte_offset),
("y", 8)
);
assert_eq!(
(members[2].name.as_str(), members[2].byte_offset),
("id", 16)
);
assert!(matches!(
members[0].datatype,
Datatype::FloatingPoint { size: 8, .. }
));
assert!(matches!(
members[2].datatype,
Datatype::FixedPoint {
size: 4,
signed: true,
..
}
));
}
_ => panic!("expected Compound"),
}
}
#[test]
fn test_array_v5_from_hdf5_2_0() {
// Real datatype message from h5py 3.16 / HDF5 2.0 (`libver=latest`) for
// an array dtype `('f8', (3,))`: datatype version 5, class 10, reusing
// the v3 array encoding (ndims, dims, base type).
let bytes: [u8; 33] = [
0x5a, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x11,
0x20, 0x3f, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x34, 0x0b, 0x00,
0x34, 0xff, 0x03, 0x00, 0x00,
];
let (dt, _) = Datatype::parse(&bytes).unwrap();
match dt {
Datatype::Array {
base_type,
dimensions,
} => {
assert_eq!(dimensions, vec![3]);
assert!(matches!(*base_type, Datatype::FloatingPoint { size: 8, .. }));
}
other => panic!("expected Array, got {other:?}"),
}
}
#[test] #[test]
fn test_reference_object() { fn test_reference_object() {
let buf = build_dt_header(7, 1, [0, 0, 0], 8); let buf = build_dt_header(7, 1, [0, 0, 0], 8);
+492 -32
View File
@@ -33,6 +33,11 @@ const SUPERBLOCK_SIZE: usize = 48;
/// Threshold for switching from compact (inline) to dense attribute storage. /// Threshold for switching from compact (inline) to dense attribute storage.
const DENSE_ATTR_THRESHOLD: usize = 8; const DENSE_ATTR_THRESHOLD: usize = 8;
/// Threshold for switching a group from compact (inline Link messages) to dense
/// link storage (fractal heap + v2 B-tree), matching libhdf5's default
/// `max_compact` of 8 links.
const DENSE_LINK_THRESHOLD: usize = 8;
// ---- OH builders ---- // ---- OH builders ----
pub(crate) fn build_chunked_dataset_oh( pub(crate) fn build_chunked_dataset_oh(
@@ -123,10 +128,16 @@ pub(crate) fn build_compact_dataset_oh(
pub(crate) fn build_group_oh( pub(crate) fn build_group_oh(
links: &[LinkMessage], links: &[LinkMessage],
dense_link_info: Option<&[u8]>,
attrs: &[AttributeMessage], attrs: &[AttributeMessage],
dense_blob: Option<&DenseAttrBlob>, dense_blob: Option<&DenseAttrBlob>,
) -> Vec<u8> { ) -> Vec<u8> {
let mut w = ObjectHeaderWriter::new(); let mut w = ObjectHeaderWriter::new();
if let Some(li) = dense_link_info {
// Dense link storage: a LinkInfo pointing at the fractal heap + name
// B-tree, and no inline Link messages.
w.add_message(MessageType::LinkInfo, li.to_vec());
} else {
let mut li = Vec::new(); let mut li = Vec::new();
li.push(0); // version li.push(0); // version
li.push(0); // flags li.push(0); // flags
@@ -136,6 +147,7 @@ pub(crate) fn build_group_oh(
for link in links { for link in links {
w.add_message(MessageType::Link, link.serialize(OFFSET_SIZE)); w.add_message(MessageType::Link, link.serialize(OFFSET_SIZE));
} }
}
if let Some(blob) = dense_blob { if let Some(blob) = dense_blob {
w.add_message(MessageType::AttributeInfo, blob.attr_info_message.clone()); w.add_message(MessageType::AttributeInfo, blob.attr_info_message.clone());
} else { } else {
@@ -167,21 +179,39 @@ pub(crate) struct DenseAttrBlob {
pub(crate) blob: Vec<u8>, pub(crate) blob: Vec<u8>,
} }
/// Build dense attribute storage for a set of attributes. /// A fractal heap holding a set of serialized objects, plus the heap IDs that
pub(crate) fn build_dense_attrs(attrs: &[AttributeMessage], base_address: u64) -> DenseAttrBlob { /// address them. Shared by dense attribute and dense link storage, which differ
// Dense attrs use v3 attribute messages (adds character set encoding byte). /// only in their v2 B-tree record layout.
let serialized: Vec<Vec<u8>> = attrs.iter().map(|a| a.serialize_v3(LENGTH_SIZE)).collect(); pub(crate) struct FractalHeapBlock {
/// The complete heap bytes: FRHP header, then either a single root direct
let name_hashes: Vec<u32> = attrs /// block, or a root indirect block (FHIB) followed by its direct blocks.
.iter() blob: Vec<u8>,
.map(|a| crate::checksum::jenkins_lookup3(a.name.as_bytes())) /// Address of the fractal heap header.
.collect(); frhp_addr: u64,
/// Address where the v2 B-tree should be placed (right after the heap).
btree_addr: u64,
/// Heap ID for each object, in input order.
heap_ids: Vec<Vec<u8>>,
/// Heap ID length (bytes).
heap_id_length: u16,
}
/// Build a fractal heap for `serialized` objects, laid out at `base_address`.
///
/// Uses a single root direct block when the data fits in one (≤ the maximum
/// direct block size), otherwise a root indirect block over multiple direct
/// blocks following the doubling table. The caller builds the matching v2
/// B-tree (type 5 for links, type 8 for attributes) at the returned
/// `btree_addr`.
pub(crate) fn build_single_block_fractal_heap(
serialized: &[Vec<u8>],
base_address: u64,
max_heap_size: u16,
heap_id_length: u16,
) -> FractalHeapBlock {
let os = OFFSET_SIZE as usize; let os = OFFSET_SIZE as usize;
let ls = LENGTH_SIZE as usize; let ls = LENGTH_SIZE as usize;
let max_heap_size: u16 = 40; let block_offset_bytes = (max_heap_size as usize).div_ceil(8);
let block_offset_bytes = (max_heap_size as usize).div_ceil(8); // 5
let heap_id_length: u16 = 8;
let max_direct_block_size: u64 = 65536; let max_direct_block_size: u64 = 65536;
// Direct block layout: sig(4) + ver(1) + heap_addr(os) + block_offset(bo_bytes) // Direct block layout: sig(4) + ver(1) + heap_addr(os) + block_offset(bo_bytes)
@@ -191,6 +221,17 @@ pub(crate) fn build_dense_attrs(attrs: &[AttributeMessage], base_address: u64) -
let dblock_content_size = dblock_header_size + total_data_size; let dblock_content_size = dblock_header_size + total_data_size;
let starting_block_size = dblock_content_size.next_power_of_two().max(512) as u64; let starting_block_size = dblock_content_size.next_power_of_two().max(512) as u64;
// When the objects don't fit in a single direct block, fall back to a
// multi-block heap with a root indirect block.
if starting_block_size > max_direct_block_size {
return build_multiblock_fractal_heap(
serialized,
base_address,
max_heap_size,
heap_id_length,
);
}
// Fractal heap header size // Fractal heap header size
let frhp_size = 4 let frhp_size = 4
+ 1 + 1
@@ -242,7 +283,7 @@ pub(crate) fn build_dense_attrs(attrs: &[AttributeMessage], base_address: u64) -
write_length(&mut frhp, starting_block_size, LENGTH_SIZE); // managed_space_in_heap write_length(&mut frhp, starting_block_size, LENGTH_SIZE); // managed_space_in_heap
write_length(&mut frhp, starting_block_size, LENGTH_SIZE); // allocated_managed_space write_length(&mut frhp, starting_block_size, LENGTH_SIZE); // allocated_managed_space
write_length(&mut frhp, 0, LENGTH_SIZE); // dblock_alloc_iter write_length(&mut frhp, 0, LENGTH_SIZE); // dblock_alloc_iter
write_length(&mut frhp, attrs.len() as u64, LENGTH_SIZE); // managed_objects_count write_length(&mut frhp, serialized.len() as u64, LENGTH_SIZE); // managed_objects_count
write_length(&mut frhp, 0, LENGTH_SIZE); // huge_objects_size write_length(&mut frhp, 0, LENGTH_SIZE); // huge_objects_size
write_length(&mut frhp, 0, LENGTH_SIZE); // huge_objects_count write_length(&mut frhp, 0, LENGTH_SIZE); // huge_objects_count
write_length(&mut frhp, 0, LENGTH_SIZE); // tiny_objects_size write_length(&mut frhp, 0, LENGTH_SIZE); // tiny_objects_size
@@ -270,10 +311,10 @@ pub(crate) fn build_dense_attrs(attrs: &[AttributeMessage], base_address: u64) -
debug_assert_eq!(dblock.len(), dblock_header_size); debug_assert_eq!(dblock.len(), dblock_header_size);
// Data area starts after header // Data area starts after header
let mut attr_offsets: Vec<(u64, u64)> = Vec::with_capacity(attrs.len()); let mut obj_offsets: Vec<(u64, u64)> = Vec::with_capacity(serialized.len());
for s in &serialized { for s in serialized {
let offset_in_heap = dblock.len() as u64; let offset_in_heap = dblock.len() as u64;
attr_offsets.push((offset_in_heap, s.len() as u64)); obj_offsets.push((offset_in_heap, s.len() as u64));
dblock.extend_from_slice(s); dblock.extend_from_slice(s);
} }
@@ -286,11 +327,283 @@ pub(crate) fn build_dense_attrs(attrs: &[AttributeMessage], base_address: u64) -
debug_assert_eq!(dblock.len(), starting_block_size as usize); debug_assert_eq!(dblock.len(), starting_block_size as usize);
// Build heap IDs // Build heap IDs
let heap_ids: Vec<Vec<u8>> = attr_offsets let heap_ids: Vec<Vec<u8>> = obj_offsets
.iter() .iter()
.map(|(off, len)| encode_managed_id(*off, *len, max_heap_size, heap_id_length)) .map(|(off, len)| encode_managed_id(*off, *len, max_heap_size, heap_id_length))
.collect(); .collect();
let mut blob = Vec::with_capacity(frhp.len() + dblock.len());
blob.extend_from_slice(&frhp);
blob.extend_from_slice(&dblock);
FractalHeapBlock {
blob,
frhp_addr,
btree_addr,
heap_ids,
heap_id_length,
}
}
/// Build a multi-block fractal heap: a root indirect block (FHIB) over multiple
/// direct blocks sized by the doubling table. Used when the objects don't fit
/// in a single direct block. Objects do not span blocks (no huge-object path).
fn build_multiblock_fractal_heap(
serialized: &[Vec<u8>],
base_address: u64,
max_heap_size: u16,
heap_id_length: u16,
) -> FractalHeapBlock {
let os = OFFSET_SIZE as usize;
let block_offset_bytes = (max_heap_size as usize).div_ceil(8);
let max_direct_block_size: u64 = 65536;
let table_width: u16 = 4;
let starting_block_size: u64 = 512;
let dblock_header_size = 4 + 1 + os + block_offset_bytes + 4;
let block_capacity = |row: usize| block_size_for_row(starting_block_size, row) - dblock_header_size as u64;
// ---- Pack objects into direct blocks (row-major over the doubling table) ----
struct Blk {
row: usize,
size: u64,
heap_offset: u64,
data: Vec<u8>,
}
let mut blocks: Vec<Blk> = Vec::new();
// Each object's (heap_offset, length) for the heap ID.
let mut obj_loc: Vec<(u64, u64)> = vec![(0, 0); serialized.len()];
let mut row = 0usize;
let mut col = 0u16;
let mut heap_off = 0u64;
let mut cur: Option<Blk> = None;
for (idx, s) in serialized.iter().enumerate() {
loop {
if cur.is_none() {
let size = block_size_for_row(starting_block_size, row);
cur = Some(Blk {
row,
size,
heap_offset: heap_off,
data: Vec::new(),
});
}
let blk = cur.as_mut().unwrap();
let cap = block_capacity(blk.row) as usize;
if !blk.data.is_empty() && blk.data.len() + s.len() > cap {
// Doesn't fit; finalize this block and advance to the next slot.
let finished = cur.take().unwrap();
heap_off += finished.size;
blocks.push(finished);
col += 1;
if col >= table_width {
col = 0;
row += 1;
}
continue;
}
// Place the object (a fresh block always accepts at least one object
// up to its capacity; objects larger than a max block are unsupported).
let pos_in_block = dblock_header_size + blk.data.len();
obj_loc[idx] = (blk.heap_offset + pos_in_block as u64, s.len() as u64);
blk.data.extend_from_slice(s);
break;
}
}
if let Some(b) = cur.take() {
blocks.push(b);
}
let cur_rows = (blocks.last().map(|b| b.row).unwrap_or(0) + 1) as u16;
// ---- Addresses ----
let frhp_size = frhp_header_size(os, LENGTH_SIZE as usize);
let frhp_addr = base_address;
let fhib_addr = frhp_addr + frhp_size as u64;
let fhib_entries = cur_rows as usize * table_width as usize;
let fhib_size = 5 + os + block_offset_bytes + fhib_entries * os + 4;
let first_dblock_addr = fhib_addr + fhib_size as u64;
// Assign each used block an address (laid out consecutively after the FHIB).
let mut blk_addrs: Vec<u64> = Vec::with_capacity(blocks.len());
let mut a = first_dblock_addr;
for b in &blocks {
blk_addrs.push(a);
a += b.size;
}
let heap_end = a;
let btree_addr = heap_end;
// Bookkeeping totals.
let managed_space: u64 = (0..cur_rows as usize)
.map(|r| block_size_for_row(starting_block_size, r) * table_width as u64)
.sum();
let alloc_space: u64 = blocks.iter().map(|b| b.size).sum();
let used: u64 = blocks
.iter()
.map(|b| dblock_header_size as u64 + b.data.len() as u64)
.sum();
let free_space = alloc_space.saturating_sub(used);
// ---- FRHP header ----
let max_managed = max_direct_block_size as u32 - dblock_header_size as u32;
let frhp = write_frhp(WriteFrhp {
heap_id_length,
max_managed,
free_space,
managed_space,
alloc_space,
nobjects: serialized.len() as u64,
table_width,
starting_block_size,
max_direct_block_size,
max_heap_size,
root_addr: fhib_addr,
cur_rows,
});
debug_assert_eq!(frhp.len(), frhp_size);
// ---- Root indirect block (FHIB) ----
let mut fhib = Vec::with_capacity(fhib_size);
fhib.extend_from_slice(b"FHIB");
fhib.push(0); // version
write_offset(&mut fhib, frhp_addr, OFFSET_SIZE);
fhib.extend_from_slice(&vec![0u8; block_offset_bytes]); // block offset = 0 (root)
for &addr in &blk_addrs {
write_offset(&mut fhib, addr, OFFSET_SIZE);
}
// Remaining slots within the current rows are unallocated.
for _ in blk_addrs.len()..fhib_entries {
write_undef_offset(&mut fhib, OFFSET_SIZE);
}
let fhib_checksum = crate::checksum::jenkins_lookup3(&fhib);
fhib.extend_from_slice(&fhib_checksum.to_le_bytes());
debug_assert_eq!(fhib.len(), fhib_size);
// ---- Direct blocks ----
let mut blob = frhp;
blob.extend_from_slice(&fhib);
for b in &blocks {
let mut dblock = Vec::with_capacity(b.size as usize);
dblock.extend_from_slice(b"FHDB");
dblock.push(0); // version
write_offset(&mut dblock, frhp_addr, OFFSET_SIZE);
let mut bo = b.heap_offset.to_le_bytes().to_vec();
bo.truncate(block_offset_bytes);
dblock.extend_from_slice(&bo);
let cksum_pos = dblock.len();
dblock.extend_from_slice(&[0u8; 4]); // checksum placeholder
dblock.extend_from_slice(&b.data);
dblock.resize(b.size as usize, 0);
let cksum = crate::checksum::jenkins_lookup3(&dblock);
dblock[cksum_pos..cksum_pos + 4].copy_from_slice(&cksum.to_le_bytes());
blob.extend_from_slice(&dblock);
}
let heap_ids: Vec<Vec<u8>> = obj_loc
.iter()
.map(|(off, len)| encode_managed_id(*off, *len, max_heap_size, heap_id_length))
.collect();
FractalHeapBlock {
blob,
frhp_addr,
btree_addr,
heap_ids,
heap_id_length,
}
}
/// Doubling-table block size for `row`: rows 0 and 1 share the starting size;
/// row r (r ≥ 1) is `start * 2^(r-1)`.
fn block_size_for_row(starting_block_size: u64, row: usize) -> u64 {
if row <= 1 {
starting_block_size
} else {
starting_block_size << (row - 1)
}
}
/// Size in bytes of the FRHP header for the given offset/length sizes.
fn frhp_header_size(os: usize, ls: usize) -> usize {
4 + 1 + 2 + 2 + 1 + 4 + ls + os + ls + os + ls + ls + ls + ls + ls + ls + ls + ls + 2 + ls + ls
+ 2
+ 2
+ os
+ 2
+ 4
}
/// Parameters for [`write_frhp`].
struct WriteFrhp {
heap_id_length: u16,
max_managed: u32,
free_space: u64,
managed_space: u64,
alloc_space: u64,
nobjects: u64,
table_width: u16,
starting_block_size: u64,
max_direct_block_size: u64,
max_heap_size: u16,
root_addr: u64,
cur_rows: u16,
}
/// Serialize a fractal heap header (FRHP).
fn write_frhp(p: WriteFrhp) -> Vec<u8> {
let mut frhp = Vec::with_capacity(frhp_header_size(OFFSET_SIZE as usize, LENGTH_SIZE as usize));
frhp.extend_from_slice(b"FRHP");
frhp.push(0); // version
frhp.extend_from_slice(&p.heap_id_length.to_le_bytes());
frhp.extend_from_slice(&0u16.to_le_bytes()); // io_filter_encoded_length
frhp.push(0x02); // flags: bit 1 = checksum direct blocks
frhp.extend_from_slice(&p.max_managed.to_le_bytes());
write_length(&mut frhp, 0, LENGTH_SIZE); // next_huge_object_id
write_undef_offset(&mut frhp, OFFSET_SIZE); // btree_huge_objects_address
write_length(&mut frhp, p.free_space, LENGTH_SIZE); // free_space_managed_blocks
write_undef_offset(&mut frhp, OFFSET_SIZE); // free_space_mgr_addr
write_length(&mut frhp, p.managed_space, LENGTH_SIZE); // managed_space_in_heap
write_length(&mut frhp, p.alloc_space, LENGTH_SIZE); // allocated_managed_space
write_length(&mut frhp, 0, LENGTH_SIZE); // dblock_alloc_iter
write_length(&mut frhp, p.nobjects, LENGTH_SIZE); // managed_objects_count
write_length(&mut frhp, 0, LENGTH_SIZE); // huge_objects_size
write_length(&mut frhp, 0, LENGTH_SIZE); // huge_objects_count
write_length(&mut frhp, 0, LENGTH_SIZE); // tiny_objects_size
write_length(&mut frhp, 0, LENGTH_SIZE); // tiny_objects_count
frhp.extend_from_slice(&p.table_width.to_le_bytes());
write_length(&mut frhp, p.starting_block_size, LENGTH_SIZE);
write_length(&mut frhp, p.max_direct_block_size, LENGTH_SIZE);
frhp.extend_from_slice(&p.max_heap_size.to_le_bytes());
frhp.extend_from_slice(&1u16.to_le_bytes()); // starting # rows in root indirect block
write_offset(&mut frhp, p.root_addr, OFFSET_SIZE);
frhp.extend_from_slice(&p.cur_rows.to_le_bytes());
let checksum = crate::checksum::jenkins_lookup3(&frhp);
frhp.extend_from_slice(&checksum.to_le_bytes());
frhp
}
/// Build dense attribute storage for a set of attributes.
pub(crate) fn build_dense_attrs(attrs: &[AttributeMessage], base_address: u64) -> DenseAttrBlob {
// Dense attrs use v3 attribute messages (adds character set encoding byte).
let serialized: Vec<Vec<u8>> = attrs.iter().map(|a| a.serialize_v3(LENGTH_SIZE)).collect();
let name_hashes: Vec<u32> = attrs
.iter()
.map(|a| crate::checksum::jenkins_lookup3(a.name.as_bytes()))
.collect();
let os = OFFSET_SIZE as usize;
let ls = LENGTH_SIZE as usize;
// Attribute heaps use max_heap_size 40 / heap ID length 8 (matching libhdf5).
let heap = build_single_block_fractal_heap(&serialized, base_address, 40, 8);
let frhp_addr = heap.frhp_addr;
let btree_addr = heap.btree_addr;
let heap_id_length = heap.heap_id_length;
let heap_ids = &heap.heap_ids;
// Build B-tree v2 type 8 records (17 bytes each) // Build B-tree v2 type 8 records (17 bytes each)
let record_size: u16 = heap_id_length + 1 + 4 + 4; let record_size: u16 = heap_id_length + 1 + 4 + 4;
let mut records: Vec<(u32, u32, Vec<u8>)> = Vec::with_capacity(attrs.len()); let mut records: Vec<(u32, u32, Vec<u8>)> = Vec::with_capacity(attrs.len());
@@ -342,9 +655,9 @@ pub(crate) fn build_dense_attrs(attrs: &[AttributeMessage], base_address: u64) -
// Pad to node_size // Pad to node_size
btlf.resize(node_size as usize, 0); btlf.resize(node_size as usize, 0);
let mut blob = Vec::with_capacity(frhp.len() + dblock.len() + bthd.len() + btlf.len()); let mut blob =
blob.extend_from_slice(&frhp); Vec::with_capacity(heap.blob.len() + bthd.len() + btlf.len());
blob.extend_from_slice(&dblock); blob.extend_from_slice(&heap.blob);
blob.extend_from_slice(&bthd); blob.extend_from_slice(&bthd);
blob.extend_from_slice(&btlf); blob.extend_from_slice(&btlf);
@@ -356,6 +669,107 @@ pub(crate) fn build_dense_attrs(attrs: &[AttributeMessage], base_address: u64) -
} }
} }
// ---- Dense link blob ----
/// Pre-built dense link storage (fractal heap + B-tree v2 + link-info message).
pub(crate) struct DenseLinkBlob {
/// Serialized LinkInfo message (to embed in the group's object header).
pub(crate) link_info_message: Vec<u8>,
/// The combined fractal heap header + direct block + B-tree v2 bytes.
pub(crate) blob: Vec<u8>,
}
/// Build dense link storage for a group's links, laid out at `base_address`.
///
/// Mirrors [`build_dense_attrs`]: each link is stored as a serialized Link
/// message in a single-direct-block fractal heap, indexed by a v2 B-tree of
/// **type 5** (link-name index, record = name hash + heap ID). The returned
/// LinkInfo message points at the heap and the name B-tree.
pub(crate) fn build_dense_links(links: &[LinkMessage], base_address: u64) -> DenseLinkBlob {
let serialized: Vec<Vec<u8>> = links.iter().map(|l| l.serialize(OFFSET_SIZE)).collect();
let name_hashes: Vec<u32> = links
.iter()
.map(|l| crate::checksum::jenkins_lookup3(l.name.as_bytes()))
.collect();
let os = OFFSET_SIZE as usize;
let ls = LENGTH_SIZE as usize;
// libhdf5's link heap uses max_heap_size 32 / heap ID length 7 (vs 40/8 for
// attributes), giving a 7-byte heap ID and an 11-byte type-5 record.
let heap = build_single_block_fractal_heap(&serialized, base_address, 32, 7);
let heap_id_length = heap.heap_id_length;
// B-tree v2 type 5 records: hash(4) + heap_id(heap_id_length). The B-tree
// search key is the name hash, so records are sorted by (hash, order).
let record_size: u16 = 4 + heap_id_length;
let mut records: Vec<(u32, u32, Vec<u8>)> = Vec::with_capacity(links.len());
for (i, heap_id) in heap.heap_ids.iter().enumerate() {
let mut rec = Vec::with_capacity(record_size as usize);
rec.extend_from_slice(&name_hashes[i].to_le_bytes()); // hash
rec.extend_from_slice(heap_id); // heap ID
records.push((name_hashes[i], i as u32, rec));
}
records.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
let bthd_size = 4 + 1 + 1 + 4 + 2 + 2 + 1 + 1 + os + 2 + ls + 4;
let num_records = links.len();
let btlf_size = 4 + 1 + 1 + (num_records * record_size as usize) + 4;
let node_size = btlf_size.next_power_of_two().max(512) as u32;
let bthd_addr = heap.btree_addr;
let btlf_addr = bthd_addr + bthd_size as u64;
let mut bthd = Vec::with_capacity(bthd_size);
bthd.extend_from_slice(b"BTHD");
bthd.push(0); // version
bthd.push(5); // type = link name index
bthd.extend_from_slice(&node_size.to_le_bytes());
bthd.extend_from_slice(&record_size.to_le_bytes());
bthd.extend_from_slice(&0u16.to_le_bytes()); // depth = 0 (single leaf)
bthd.push(100); // split_percent
bthd.push(40); // merge_percent
write_offset(&mut bthd, btlf_addr, OFFSET_SIZE);
bthd.extend_from_slice(&(num_records as u16).to_le_bytes());
write_length(&mut bthd, num_records as u64, LENGTH_SIZE);
let bthd_checksum = crate::checksum::jenkins_lookup3(&bthd);
bthd.extend_from_slice(&bthd_checksum.to_le_bytes());
debug_assert_eq!(bthd.len(), bthd_size);
let mut btlf = Vec::with_capacity(node_size as usize);
btlf.extend_from_slice(b"BTLF");
btlf.push(0); // version
btlf.push(5); // type
for (_, _, rec) in &records {
btlf.extend_from_slice(rec);
}
let btlf_checksum = crate::checksum::jenkins_lookup3(&btlf);
btlf.extend_from_slice(&btlf_checksum.to_le_bytes());
btlf.resize(node_size as usize, 0);
let mut blob =
Vec::with_capacity(heap.blob.len() + bthd.len() + btlf.len());
blob.extend_from_slice(&heap.blob);
blob.extend_from_slice(&bthd);
blob.extend_from_slice(&btlf);
DenseLinkBlob {
link_info_message: serialize_link_info(heap.frhp_addr, bthd_addr),
blob,
}
}
/// Serialize a LinkInfo message (version 0, no creation-order index) pointing
/// at a fractal heap and a v2 B-tree name index.
fn serialize_link_info(fh_addr: u64, btree_name_addr: u64) -> Vec<u8> {
let mut data = Vec::new();
data.push(0); // version
data.push(0x00); // flags: no creation-order tracking
write_offset(&mut data, fh_addr, OFFSET_SIZE);
write_offset(&mut data, btree_name_addr, OFFSET_SIZE);
data
}
fn encode_managed_id(offset: u64, length: u64, max_heap_size: u16, id_length: u16) -> Vec<u8> { fn encode_managed_id(offset: u64, length: u64, max_heap_size: u16, id_length: u16) -> Vec<u8> {
let mut id = vec![0u8; id_length as usize]; let mut id = vec![0u8; id_length as usize];
id[0] = 0x00; // type = 0 (managed) id[0] = 0x00; // type = 0 (managed)
@@ -599,6 +1013,18 @@ impl FileWriter {
.map(|d| d.attrs.len() > DENSE_ATTR_THRESHOLD) .map(|d| d.attrs.len() > DENSE_ATTR_THRESHOLD)
.collect(); .collect();
// Dense link decision: a group with more than the compact threshold of
// links stores them in a fractal heap + v2 B-tree instead of inline.
let root_link_count = root_ds_indices.len() + groups.len();
let root_links_dense = root_link_count > DENSE_LINK_THRESHOLD;
let group_links_dense: Vec<bool> = groups
.iter()
.map(|g| g.ds_indices.len() > DENSE_LINK_THRESHOLD)
.collect();
// The dense LinkInfo message is a fixed size regardless of address, so a
// dummy is sufficient for OH size computation.
let dummy_link_info = serialize_link_info(0, 0);
// Pass 1: compute OH sizes with dummy addresses // Pass 1: compute OH sizes with dummy addresses
let group_oh_sizes: Vec<usize> = groups let group_oh_sizes: Vec<usize> = groups
.iter() .iter()
@@ -609,12 +1035,9 @@ impl FileWriter {
.iter() .iter()
.map(|&i| make_link(&all_ds[i].name, 0)) .map(|&i| make_link(&all_ds[i].name, 0))
.collect(); .collect();
if group_dense[gi] { let attr_blob = group_dense[gi].then(|| build_dense_attrs(&g.attrs, 0));
let dummy_blob = build_dense_attrs(&g.attrs, 0); let dl = group_links_dense[gi].then_some(dummy_link_info.as_slice());
build_group_oh(&dummy_links, &g.attrs, Some(&dummy_blob)).len() build_group_oh(&dummy_links, dl, &g.attrs, attr_blob.as_ref()).len()
} else {
build_group_oh(&dummy_links, &g.attrs, None).len()
}
}) })
.collect(); .collect();
@@ -628,11 +1051,10 @@ impl FileWriter {
} }
links links
}; };
let root_oh_size = if root_dense { let root_oh_size = {
let dummy_blob = build_dense_attrs(&root_attrs, 0); let attr_blob = root_dense.then(|| build_dense_attrs(&root_attrs, 0));
build_group_oh(&root_dummy_links, &root_attrs, Some(&dummy_blob)).len() let dl = root_links_dense.then_some(dummy_link_info.as_slice());
} else { build_group_oh(&root_dummy_links, dl, &root_attrs, attr_blob.as_ref()).len()
build_group_oh(&root_dummy_links, &root_attrs, None).len()
}; };
struct DataBlob { struct DataBlob {
@@ -720,6 +1142,17 @@ impl FileWriter {
let root_group_addr = SUPERBLOCK_SIZE as u64; let root_group_addr = SUPERBLOCK_SIZE as u64;
let mut cursor2 = SUPERBLOCK_SIZE + root_oh_size; let mut cursor2 = SUPERBLOCK_SIZE + root_oh_size;
// Each group is laid out as: object header, then (if dense) its link
// blob, then (if dense) its attribute blob. Link blobs are sized with
// dummy target addresses here — link message size is address-independent
// — and rebuilt with real addresses in the final pass.
let root_link_blob_addr = if root_links_dense {
let addr = cursor2 as u64;
cursor2 += build_dense_links(&root_dummy_links, addr).blob.len();
Some(addr)
} else {
None
};
let root_dense_blob = if root_dense { let root_dense_blob = if root_dense {
let blob = build_dense_attrs(&root_attrs, cursor2 as u64); let blob = build_dense_attrs(&root_attrs, cursor2 as u64);
cursor2 += blob.blob.len(); cursor2 += blob.blob.len();
@@ -728,6 +1161,7 @@ impl FileWriter {
None None
}; };
let mut group_link_blob_addrs: Vec<Option<u64>> = Vec::new();
let mut group_dense_blobs: Vec<Option<DenseAttrBlob>> = Vec::new(); let mut group_dense_blobs: Vec<Option<DenseAttrBlob>> = Vec::new();
let group_addrs2: Vec<u64> = group_oh_sizes let group_addrs2: Vec<u64> = group_oh_sizes
.iter() .iter()
@@ -735,6 +1169,18 @@ impl FileWriter {
.map(|(gi, &sz)| { .map(|(gi, &sz)| {
let addr = cursor2 as u64; let addr = cursor2 as u64;
cursor2 += sz; cursor2 += sz;
if group_links_dense[gi] {
let dummy_links: Vec<LinkMessage> = groups[gi]
.ds_indices
.iter()
.map(|&i| make_link(&all_ds[i].name, 0))
.collect();
let blob_addr = cursor2 as u64;
cursor2 += build_dense_links(&dummy_links, blob_addr).blob.len();
group_link_blob_addrs.push(Some(blob_addr));
} else {
group_link_blob_addrs.push(None);
}
if group_dense[gi] { if group_dense[gi] {
let blob = build_dense_attrs(&groups[gi].attrs, cursor2 as u64); let blob = build_dense_attrs(&groups[gi].attrs, cursor2 as u64);
cursor2 += blob.blob.len(); cursor2 += blob.blob.len();
@@ -868,27 +1314,41 @@ impl FileWriter {
for (gi, g) in groups.iter().enumerate() { for (gi, g) in groups.iter().enumerate() {
root_links.push(make_link(&g.name, group_addrs2[gi])); root_links.push(make_link(&g.name, group_addrs2[gi]));
} }
// Rebuild the root link blob with real target addresses (same size as
// the dummy used for layout); its LinkInfo goes in the OH.
let root_link_blob = root_link_blob_addr.map(|addr| build_dense_links(&root_links, addr));
let root_dl = root_link_blob.as_ref().map(|b| b.link_info_message.as_slice());
buf.extend_from_slice(&build_group_oh( buf.extend_from_slice(&build_group_oh(
&root_links, &root_links,
root_dl,
&root_attrs, &root_attrs,
root_dense_blob.as_ref(), root_dense_blob.as_ref(),
)); ));
if let Some(ref b) = root_link_blob {
buf.extend_from_slice(&b.blob);
}
if let Some(ref blob) = root_dense_blob { if let Some(ref blob) = root_dense_blob {
buf.extend_from_slice(&blob.blob); buf.extend_from_slice(&blob.blob);
} }
// Group OHs + dense blobs // Group OHs + dense blobs (link blob, then attr blob, matching pass 2)
for (gi, g) in groups.iter().enumerate() { for (gi, g) in groups.iter().enumerate() {
let links: Vec<LinkMessage> = g let links: Vec<LinkMessage> = g
.ds_indices .ds_indices
.iter() .iter()
.map(|&i| make_link(&all_ds[i].name, ds_oh_addrs2[i])) .map(|&i| make_link(&all_ds[i].name, ds_oh_addrs2[i]))
.collect(); .collect();
let link_blob = group_link_blob_addrs[gi].map(|addr| build_dense_links(&links, addr));
let dl = link_blob.as_ref().map(|b| b.link_info_message.as_slice());
buf.extend_from_slice(&build_group_oh( buf.extend_from_slice(&build_group_oh(
&links, &links,
dl,
&g.attrs, &g.attrs,
group_dense_blobs[gi].as_ref(), group_dense_blobs[gi].as_ref(),
)); ));
if let Some(ref b) = link_blob {
buf.extend_from_slice(&b.blob);
}
if let Some(ref blob) = group_dense_blobs[gi] { if let Some(ref blob) = group_dense_blobs[gi] {
buf.extend_from_slice(&blob.blob); buf.extend_from_slice(&blob.blob);
} }
+740 -2
View File
@@ -8,7 +8,8 @@ use alloc::{vec, vec::Vec};
use crate::error::FormatError; use crate::error::FormatError;
use crate::filter_pipeline::{ use crate::filter_pipeline::{
FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_SHUFFLE, FILTER_ZSTD, FilterPipeline, FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_NBIT, FILTER_SCALEOFFSET, FILTER_SHUFFLE,
FILTER_ZSTD, FilterPipeline,
}; };
/// Apply a filter pipeline to decompress a chunk. /// Apply a filter pipeline to decompress a chunk.
@@ -16,7 +17,7 @@ use crate::filter_pipeline::{
pub fn decompress_chunk( pub fn decompress_chunk(
compressed: &[u8], compressed: &[u8],
pipeline: &FilterPipeline, pipeline: &FilterPipeline,
_chunk_size: usize, chunk_size: usize,
element_size: u32, element_size: u32,
) -> Result<Vec<u8>, FormatError> { ) -> Result<Vec<u8>, FormatError> {
let mut data = compressed.to_vec(); let mut data = compressed.to_vec();
@@ -28,6 +29,10 @@ pub fn decompress_chunk(
FILTER_LZ4 => lz4_decompress(&data)?, FILTER_LZ4 => lz4_decompress(&data)?,
FILTER_ZSTD => zstd_decompress(&data)?, FILTER_ZSTD => zstd_decompress(&data)?,
FILTER_FLETCHER32 => fletcher32_verify(&data)?, FILTER_FLETCHER32 => fletcher32_verify(&data)?,
// `chunk_size` is the expected decompressed size; pass it so these
// decoders can reject an element count that would over-allocate.
FILTER_SCALEOFFSET => scaleoffset_decompress(&data, &filter.client_data, chunk_size)?,
FILTER_NBIT => nbit_decompress(&data, &filter.client_data, chunk_size)?,
other => return Err(FormatError::UnsupportedFilter(other)), other => return Err(FormatError::UnsupportedFilter(other)),
}; };
} }
@@ -64,6 +69,501 @@ pub fn compress_chunk(
Ok(result) Ok(result)
} }
/// Decode the HDF5 scale-offset filter (id 6).
///
/// Supports the integer variant (`H5Z_SO_INT`) and the floating-point
/// **D-scale** variant (`H5Z_SO_FLOAT_DSCALE`); the float E-scale variant is
/// reported as unsupported.
///
/// Compressed buffer layout (reverse-engineered against HDF5 2.0 and verified
/// across signed/unsigned int sizes, f32/f64, negatives, fill values and chunk
/// sizes): `minbits` (u32 LE) · `minval_width` (1 byte) · `minval`
/// (`minval_width` bytes — a little-endian integer for the int variant, or the
/// minimum float for D-scale) · 8 reserved bytes · MSB-first packed codes
/// (`nelmts * minbits` bits). The all-ones code is reserved for the (defined)
/// fill value. Integer reconstruction is `value = minval + code`; D-scale float
/// is `value = minval + code / 10^scale_factor`.
///
/// `cd` is the `H5Zscaleoffset.c` parameter block: `[0]`=scale type
/// (0 = float D-scale, 2 = integer), `[1]`=scale factor (decimal digits for
/// D-scale), `[2]`=element count, `[4]`=element size, `[5]`=signed flag,
/// `[6]`=byte order (1 = big-endian), `[7]`=fill defined, `[8..]`=fill value.
fn scaleoffset_decompress(
data: &[u8],
cd: &[u32],
expected_bytes: usize,
) -> Result<Vec<u8>, FormatError> {
const H5Z_SO_FLOAT_DSCALE: u32 = 0;
const H5Z_SO_INT: u32 = 2;
if cd.len() < 8 {
return Err(FormatError::ChunkedReadError(
"scale-offset: missing filter client data".into(),
));
}
let scale_type = cd[0];
let is_float = scale_type == H5Z_SO_FLOAT_DSCALE;
if scale_type != H5Z_SO_INT && !is_float {
// Float E-scale (scale type 1) uses a different algorithm.
return Err(FormatError::UnsupportedFilter(FILTER_SCALEOFFSET));
}
let nelmts = cd[2] as usize;
let elem_size = cd[4] as usize;
if elem_size == 0 || elem_size > 8 || (is_float && elem_size != 4 && elem_size != 8) {
return Err(FormatError::ChunkedReadError(
"scale-offset: unsupported element size".into(),
));
}
// The decoded output must match the chunk's uncompressed size; reject an
// element count that would over-allocate (e.g. minbits == 0 with a huge
// nelmts and no packed payload to bound it).
let out_bytes = nelmts
.checked_mul(elem_size)
.ok_or_else(|| FormatError::ChunkedReadError("scale-offset: size overflow".into()))?;
if expected_bytes != 0 && out_bytes > expected_bytes {
return Err(FormatError::ChunkedReadError(
"scale-offset: element count exceeds chunk size".into(),
));
}
let signed = cd[5] == 1;
let big_endian = cd[6] == 1;
let fill_defined = cd[7] == 1;
// --- header: minbits, then minval, then 8 reserved bytes ---
if data.len() < 5 {
return Err(FormatError::ChunkedReadError(
"scale-offset: truncated header".into(),
));
}
let minbits = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
let minval_width = data[4] as usize;
let minval_end = 5 + minval_width;
if data.len() < minval_end {
return Err(FormatError::ChunkedReadError(
"scale-offset: truncated minval".into(),
));
}
let minval_bytes = &data[5..minval_end];
// --- unpack the per-element codes (MSB-first), shared by both variants ---
if minbits > 64 {
return Err(FormatError::ChunkedReadError(
"scale-offset: implausible minbits".into(),
));
}
let codes: Vec<u64> = if minbits == 0 {
// No packed payload: every element equals minval.
vec![0u64; nelmts]
} else {
let packed = data.get(minval_end + 8..).ok_or_else(|| {
FormatError::ChunkedReadError("scale-offset: truncated packed data".into())
})?;
let need_bits = nelmts
.checked_mul(minbits)
.ok_or_else(|| FormatError::ChunkedReadError("scale-offset: size overflow".into()))?;
if packed.len() * 8 < need_bits {
return Err(FormatError::ChunkedReadError(
"scale-offset: packed data too short".into(),
));
}
let mut out = Vec::with_capacity(nelmts);
let mut bitpos = 0usize;
for _ in 0..nelmts {
let mut code: u64 = 0;
for _ in 0..minbits {
let bit = (packed[bitpos / 8] >> (7 - (bitpos % 8))) & 1;
code = (code << 1) | bit as u64;
bitpos += 1;
}
out.push(code);
}
out
};
// The fill code (all ones) only exists when there are bits to pack.
let has_fill_code = fill_defined && minbits > 0 && minbits < 64;
// Computed for all 1..=64 widths; `1 << 64` would overflow, so saturate.
let fill_code: u64 = if minbits == 0 {
0
} else if minbits >= 64 {
u64::MAX
} else {
(1u64 << minbits) - 1
};
if is_float {
let scale = 10f64.powi(cd[1] as i32);
let minval = read_le_float(minval_bytes, elem_size);
let fill_value = if fill_defined {
let lo = *cd.get(8).unwrap_or(&0) as u64;
let hi = *cd.get(9).unwrap_or(&0) as u64;
bits_to_float(lo | (hi << 32), elem_size)
} else {
0.0
};
let values: Vec<f64> = codes
.iter()
.map(|&code| {
if has_fill_code && code == fill_code {
fill_value
} else {
minval + code as f64 / scale
}
})
.collect();
Ok(write_floats(&values, elem_size, big_endian))
} else {
let minval = read_le_int(minval_bytes, signed);
let fill_value: i64 = if fill_defined {
let lo = *cd.get(8).unwrap_or(&0) as u64;
let hi = *cd.get(9).unwrap_or(&0) as u64;
sign_extend(lo | (hi << 32), elem_size, signed)
} else {
0
};
let values: Vec<i64> = codes
.iter()
.map(|&code| {
if has_fill_code && code == fill_code {
fill_value
} else {
minval.wrapping_add(code as i64)
}
})
.collect();
Ok(write_elements(&values, elem_size, big_endian))
}
}
/// Read a little-endian float of `size` bytes (4 = f32, otherwise f64) as f64.
fn read_le_float(bytes: &[u8], size: usize) -> f64 {
if size == 4 {
let mut b = [0u8; 4];
let n = bytes.len().min(4);
b[..n].copy_from_slice(&bytes[..n]);
f32::from_le_bytes(b) as f64
} else {
let mut b = [0u8; 8];
let n = bytes.len().min(8);
b[..n].copy_from_slice(&bytes[..n]);
f64::from_le_bytes(b)
}
}
/// Interpret the low bits of `raw` as an IEEE float of `size` bytes.
fn bits_to_float(raw: u64, size: usize) -> f64 {
if size == 4 {
f32::from_bits(raw as u32) as f64
} else {
f64::from_bits(raw)
}
}
/// Serialize reconstructed float values as `elem_size`-byte (f32/f64) elements
/// in the requested byte order.
fn write_floats(values: &[f64], elem_size: usize, big_endian: bool) -> Vec<u8> {
let mut out = Vec::with_capacity(values.len() * elem_size);
for &v in values {
let bytes: [u8; 8] = if elem_size == 4 {
let mut b = [0u8; 8];
b[..4].copy_from_slice(&(v as f32).to_le_bytes());
b
} else {
v.to_le_bytes()
};
if big_endian {
for i in (0..elem_size).rev() {
out.push(bytes[i]);
}
} else {
out.extend_from_slice(&bytes[..elem_size]);
}
}
out
}
/// Read a little-endian integer of `bytes.len()` bytes, sign-extending when
/// `signed`. Used for the scale-offset `minval` field.
fn read_le_int(bytes: &[u8], signed: bool) -> i64 {
let mut raw: u64 = 0;
for (i, &b) in bytes.iter().enumerate().take(8) {
raw |= (b as u64) << (i * 8);
}
sign_extend(raw, bytes.len().min(8), signed)
}
/// Interpret the low `size` bytes of `raw` as a (possibly signed) integer.
fn sign_extend(raw: u64, size: usize, signed: bool) -> i64 {
if size == 0 || size >= 8 {
return raw as i64;
}
let bits = size * 8;
let mask = (1u64 << bits) - 1;
let val = raw & mask;
if signed && (val & (1u64 << (bits - 1))) != 0 {
(val | !mask) as i64
} else {
val as i64
}
}
/// Serialize reconstructed integer values as `elem_size`-byte elements in the
/// requested byte order.
fn write_elements(values: &[i64], elem_size: usize, big_endian: bool) -> Vec<u8> {
let mut out = Vec::with_capacity(values.len() * elem_size);
for &v in values {
let le = (v as u64).to_le_bytes();
if big_endian {
for i in (0..elem_size).rev() {
out.push(le[i]);
}
} else {
out.extend_from_slice(&le[..elem_size]);
}
}
out
}
/// A node of the N-Bit datatype tree (reconstructed from the filter's client
/// data) describing how one element is bit-packed.
enum NbitNode {
/// Leaf: `precision` significant bits at `bit_offset` of a `size`-byte field.
Atomic {
size: usize,
big_endian: bool,
precision: u32,
bit_offset: u32,
},
/// Fixed-size struct of members, each at a byte offset within the element.
Compound {
size: usize,
members: Vec<(usize, NbitNode)>,
},
/// `count` consecutive copies of `base`, each `base_size` bytes apart.
Array {
base: Box<NbitNode>,
count: usize,
base_size: usize,
},
}
impl NbitNode {
fn byte_size(&self) -> usize {
match self {
NbitNode::Atomic { size, .. } => *size,
NbitNode::Compound { size, .. } => *size,
NbitNode::Array {
count, base_size, ..
} => count * base_size,
}
}
}
fn nbit_cd(cd: &[u32], i: usize) -> Result<u32, FormatError> {
cd.get(i)
.copied()
.ok_or_else(|| FormatError::ChunkedReadError("nbit: truncated client data".into()))
}
/// Maximum N-Bit type-tree nesting depth. Real types nest only a few levels;
/// the bound stops a crafted tree from recursing into a stack overflow.
const NBIT_MAX_DEPTH: u32 = 64;
/// Parse one N-Bit type node from the client-data tree, advancing `idx`.
fn parse_nbit_node(cd: &[u32], idx: &mut usize, depth: u32) -> Result<NbitNode, FormatError> {
const ATOMIC: u32 = 1;
const ARRAY: u32 = 2;
const COMPOUND: u32 = 3;
if depth > NBIT_MAX_DEPTH {
return Err(FormatError::ChunkedReadError(
"nbit: type tree nested too deeply".into(),
));
}
let class = nbit_cd(cd, *idx)?;
match class {
ATOMIC => {
// class, size, byte order, precision, bit offset
let size = nbit_cd(cd, *idx + 1)? as usize;
let big_endian = nbit_cd(cd, *idx + 2)? == 1;
let precision = nbit_cd(cd, *idx + 3)?;
let bit_offset = nbit_cd(cd, *idx + 4)?;
*idx += 5;
let end_bit = bit_offset.checked_add(precision);
if size == 0
|| size > 8
|| precision == 0
|| end_bit.is_none_or(|e| e > (size * 8) as u32)
{
return Err(FormatError::ChunkedReadError(
"nbit: invalid atomic parameters".into(),
));
}
Ok(NbitNode::Atomic {
size,
big_endian,
precision,
bit_offset,
})
}
ARRAY => {
// class, total size, base type node
let total = nbit_cd(cd, *idx + 1)? as usize;
*idx += 2;
let base = parse_nbit_node(cd, idx, depth + 1)?;
let base_size = base.byte_size();
if base_size == 0 || !total.is_multiple_of(base_size) {
return Err(FormatError::ChunkedReadError(
"nbit: invalid array layout".into(),
));
}
Ok(NbitNode::Array {
count: total / base_size,
base_size,
base: Box::new(base),
})
}
COMPOUND => {
// class, total size, member count, (member byte offset, node)*
let total = nbit_cd(cd, *idx + 1)? as usize;
let nmembers = nbit_cd(cd, *idx + 2)? as usize;
*idx += 3;
// Don't pre-allocate from the untrusted member count; the loop is
// bounded by `nbit_cd` running out of client data.
let mut members = Vec::new();
for _ in 0..nmembers {
let moff = nbit_cd(cd, *idx)? as usize;
*idx += 1;
let node = parse_nbit_node(cd, idx, depth + 1)?;
if moff
.checked_add(node.byte_size())
.is_none_or(|end| end > total)
{
return Err(FormatError::ChunkedReadError(
"nbit: member exceeds compound size".into(),
));
}
members.push((moff, node));
}
Ok(NbitNode::Compound {
size: total,
members,
})
}
// Class 4 is H5Z_NBIT_NOOPTYPE (members copied verbatim) — not seen in
// practice for the supported leaf types and left unsupported.
_ => Err(FormatError::UnsupportedFilter(FILTER_NBIT)),
}
}
/// MSB-first bit reader over the packed N-Bit stream.
struct BitReader<'a> {
data: &'a [u8],
pos: usize,
}
impl BitReader<'_> {
fn read(&mut self, nbits: u32) -> Result<u64, FormatError> {
let mut value = 0u64;
for _ in 0..nbits {
let byte = *self.data.get(self.pos / 8).ok_or_else(|| {
FormatError::ChunkedReadError("nbit: packed data too short".into())
})?;
let bit = (byte >> (7 - (self.pos % 8))) & 1;
value = (value << 1) | bit as u64;
self.pos += 1;
}
Ok(value)
}
}
/// Decode one element node into `elem[base..]` (the rest stays zero-filled).
fn decode_nbit_node(
node: &NbitNode,
br: &mut BitReader,
elem: &mut [u8],
base: usize,
) -> Result<(), FormatError> {
match node {
NbitNode::Atomic {
size,
big_endian,
precision,
bit_offset,
} => {
let value = br.read(*precision)? << bit_offset;
let le = value.to_le_bytes();
let dst = &mut elem[base..base + size];
if *big_endian {
for (j, slot) in dst.iter_mut().enumerate() {
*slot = le[size - 1 - j];
}
} else {
dst.copy_from_slice(&le[..*size]);
}
}
NbitNode::Compound { members, .. } => {
for (moff, child) in members {
decode_nbit_node(child, br, elem, base + moff)?;
}
}
NbitNode::Array {
base: bnode,
count,
base_size,
} => {
for i in 0..*count {
decode_nbit_node(bnode, br, elem, base + i * base_size)?;
}
}
}
Ok(())
}
/// Decode the HDF5 N-Bit filter (id 5).
///
/// N-Bit strips the unused leading/trailing bits of each (possibly nested)
/// datatype field and packs the significant `precision` bits MSB-first,
/// contiguously, with no header. The filter's client data carries a recursive
/// type tree — atomic (`[1, size, order, precision, offset]`), array
/// (`[2, total_size, <base>]`) and compound
/// (`[3, total_size, nmembers, (offset, <node>)*]`) — preceded by
/// `[nparms, flag, nelmts]`. Decompression walks the tree once per element,
/// placing each field's bits at its byte/bit offset in a zero-filled element
/// (HDF5's canonical reduced-precision layout). Sign-extension of reduced
/// precision signed integers is the datatype reader's job. Atomic floats are
/// encoded as full-precision atomics and handled transparently.
fn nbit_decompress(data: &[u8], cd: &[u32], expected_bytes: usize) -> Result<Vec<u8>, FormatError> {
if cd.len() < 4 {
return Err(FormatError::ChunkedReadError(
"nbit: missing filter client data".into(),
));
}
let nelmts = cd[2] as usize;
let mut idx = 3;
let root = parse_nbit_node(cd, &mut idx, 0)?;
let elem_size = root.byte_size();
if elem_size == 0 {
return Err(FormatError::ChunkedReadError(
"nbit: zero element size".into(),
));
}
let total = nelmts
.checked_mul(elem_size)
.ok_or_else(|| FormatError::ChunkedReadError("nbit: size overflow".into()))?;
// The decoded output must match the chunk's uncompressed size; reject a
// count that would over-allocate.
if expected_bytes != 0 && total > expected_bytes {
return Err(FormatError::ChunkedReadError(
"nbit: element count exceeds chunk size".into(),
));
}
let mut out = vec![0u8; total];
let mut br = BitReader { data, pos: 0 };
for elem in out.chunks_exact_mut(elem_size) {
decode_nbit_node(&root, &mut br, elem, 0)?;
}
Ok(out)
}
/// Decompress zlib-compressed data. /// Decompress zlib-compressed data.
#[cfg(feature = "deflate")] #[cfg(feature = "deflate")]
fn deflate_decompress(data: &[u8]) -> Result<Vec<u8>, FormatError> { fn deflate_decompress(data: &[u8]) -> Result<Vec<u8>, FormatError> {
@@ -707,4 +1207,242 @@ mod tests {
let decompressed = decompress_chunk(&compressed, &pipeline, data.len(), 8).unwrap(); let decompressed = decompress_chunk(&compressed, &pipeline, data.len(), 8).unwrap();
assert_eq!(decompressed, data); assert_eq!(decompressed, data);
} }
// --- scale-offset (filter id 6) -------------------------------------------
// Inputs below are real compressed chunks + client data captured from
// h5py 3.16 / HDF5 2.0 (`scaleoffset=0`), so they guard the decoder against
// the reference implementation without needing h5py at test time.
fn i32_le(vals: &[i32]) -> Vec<u8> {
vals.iter().flat_map(|v| v.to_le_bytes()).collect()
}
#[test]
fn scaleoffset_int_basic() {
// i32 [0,1,2,3], chunk of 4, default fill 0 (element 0 is the fill).
let cd = [2u32, 0, 4, 0, 4, 1, 0, 1, 0];
let raw = [
0x02, 0x00, 0x00, 0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc6, 0x00,
];
assert_eq!(scaleoffset_decompress(&raw, &cd, 0).unwrap(), i32_le(&[0, 1, 2, 3]));
}
#[test]
fn scaleoffset_int_negative() {
// i32 [-5,-3,-1,0,2,4,7,9], chunk of 8, fill 0 (minval = -5).
let cd = [2u32, 0, 8, 0, 4, 1, 0, 1, 0];
let raw = [
0x04, 0x00, 0x00, 0x00, 0x08, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x4f, 0x79, 0xce, 0x00,
];
assert_eq!(
scaleoffset_decompress(&raw, &cd, 0).unwrap(),
i32_le(&[-5, -3, -1, 0, 2, 4, 7, 9])
);
}
#[test]
fn scaleoffset_uint() {
// u32 [100..109], chunk of 10, unsigned (cd[5]==0), minval 100.
let cd = [2u32, 0, 10, 0, 4, 0, 0, 1, 0];
let raw = [
0x04, 0x00, 0x00, 0x00, 0x08, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0x00,
];
let expected: Vec<u8> = (100u32..110).flat_map(|v| v.to_le_bytes()).collect();
assert_eq!(scaleoffset_decompress(&raw, &cd, 0).unwrap(), expected);
}
fn as_f32(bytes: &[u8]) -> Vec<f32> {
bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect()
}
#[test]
fn scaleoffset_float_dscale_d1() {
// f32 [0,1,2,3], D=1, default fill 0 (element 0 is the fill).
let cd = [0u32, 1, 4, 1, 4, 0, 0, 1, 0];
let raw = [
0x05, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x15, 0x40,
];
let got = as_f32(&scaleoffset_decompress(&raw, &cd, 0).unwrap());
assert_eq!(got, vec![0.0, 1.0, 2.0, 3.0]);
}
#[test]
fn scaleoffset_float_dscale_d3() {
// f32 [0,0.1,0.2,0.3], D=3 (lossy reconstruction within 10^-3).
let cd = [0u32, 3, 4, 1, 4, 0, 0, 1, 0];
let raw = [
0x08, 0x00, 0x00, 0x00, 0x08, 0xcd, 0xcc, 0xcc, 0x3d, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00, 0x64, 0xc8, 0x00,
];
let got = as_f32(&scaleoffset_decompress(&raw, &cd, 0).unwrap());
let exp = [0.0f32, 0.1, 0.2, 0.3];
assert_eq!(got.len(), 4);
for (g, e) in got.iter().zip(exp.iter()) {
assert!((g - e).abs() < 1e-3, "got {g} expected {e}");
}
}
#[test]
fn scaleoffset_float_escale_unsupported() {
// scale_type 1 = float E-scale — a different algorithm, must be rejected.
let cd = [1u32, 3, 50, 1, 4, 0, 0, 1, 0];
let raw = [0u8; 24];
assert!(matches!(
scaleoffset_decompress(&raw, &cd, 0),
Err(FormatError::UnsupportedFilter(FILTER_SCALEOFFSET))
));
}
// --- N-Bit (filter id 5) --------------------------------------------------
// Real compressed chunks + client data from h5py 3.16 / HDF5 2.0. The
// expected outputs are the canonical zero-filled element bytes (verified
// equal to the contiguous, un-filtered on-disk layout of the same type).
#[test]
fn nbit_unsigned_12bit() {
// u32 storage, 12-bit precision: [0, 1, 4095, 2048].
let cd = [8u32, 0, 4, 1, 4, 0, 12, 0];
let raw = [0x00, 0x00, 0x01, 0xff, 0xf8, 0x00, 0x00];
let expected: Vec<u8> = [0u32, 1, 4095, 2048]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect();
assert_eq!(nbit_decompress(&raw, &cd, 0).unwrap(), expected);
}
#[test]
fn nbit_signed_16bit_zero_filled() {
// i32 storage, 16-bit precision: [-1, -50, 100, -1000]. N-Bit restores
// the canonical zero-filled layout (high 16 bits zero); the datatype
// reader is responsible for sign-extending reduced-precision integers.
let cd = [8u32, 0, 4, 1, 4, 0, 16, 0];
let raw = [0xff, 0xff, 0xff, 0xce, 0x00, 0x64, 0xfc, 0x18, 0x00];
// Canonical bytes captured from an equivalent un-filtered dataset.
let expected: Vec<u8> = vec![
0xff, 0xff, 0x00, 0x00, // 0x0000ffff
0xce, 0xff, 0x00, 0x00, // 0x0000ffce
0x64, 0x00, 0x00, 0x00, // 0x00000064
0x18, 0xfc, 0x00, 0x00, // 0x0000fc18
];
assert_eq!(nbit_decompress(&raw, &cd, 0).unwrap(), expected);
}
#[test]
fn nbit_compound_int_members() {
// Compound { a: i32@0 prec 16, b: u32@4 prec 8 }, 3 elements.
// data = [(-1,200),(1000,7),(-32768,255)]. Captured from HDF5 2.0.
let cd = [18u32, 0, 3, 3, 8, 2, 0, 1, 4, 0, 16, 0, 4, 1, 4, 0, 8, 0];
let raw = [0xff, 0xff, 0xc8, 0x03, 0xe8, 0x07, 0x80, 0x00, 0xff, 0x00];
#[rustfmt::skip]
let expected: Vec<u8> = vec![
0xff,0xff,0x00,0x00, 0xc8,0x00,0x00,0x00, // (-1, 200)
0xe8,0x03,0x00,0x00, 0x07,0x00,0x00,0x00, // (1000, 7)
0x00,0x80,0x00,0x00, 0xff,0x00,0x00,0x00, // (-32768, 255)
];
assert_eq!(nbit_decompress(&raw, &cd, 0).unwrap(), expected);
}
#[test]
fn nbit_compound_with_array_member() {
// Compound { a: array(2,) of i32 prec 16 @0; b: u32@8 prec 8 }, 2 elements.
// data = [([-1,100],200), ([1000,-32768],7)].
let cd = [20u32, 0, 2, 3, 12, 2, 0, 2, 8, 1, 4, 0, 16, 0, 8, 1, 4, 0, 8, 0];
let raw = [0xff, 0xff, 0x00, 0x64, 0xc8, 0x03, 0xe8, 0x80, 0x00, 0x07, 0x00];
#[rustfmt::skip]
let expected: Vec<u8> = vec![
0xff,0xff,0x00,0x00, 0x64,0x00,0x00,0x00, 0xc8,0x00,0x00,0x00, // ([-1,100], 200)
0xe8,0x03,0x00,0x00, 0x00,0x80,0x00,0x00, 0x07,0x00,0x00,0x00, // ([1000,-32768], 7)
];
assert_eq!(nbit_decompress(&raw, &cd, 0).unwrap(), expected);
}
#[test]
fn nbit_compound_with_float_member() {
// Compound { i: i32@0 prec 16; f: f32@4 prec 32 }, 2 elements.
// data = [(-1, 1.5), (100, -2.5)]. The float member is a full-precision
// atomic; its bits are packed MSB-first and restored to LE storage.
let cd = [18u32, 0, 2, 3, 8, 2, 0, 1, 4, 0, 16, 0, 4, 1, 4, 0, 32, 0];
let raw = [
0xff, 0xff, 0x3f, 0xc0, 0x00, 0x00, 0x00, 0x64, 0xc0, 0x20, 0x00, 0x00, 0x00,
];
#[rustfmt::skip]
let expected: Vec<u8> = vec![
0xff,0xff,0x00,0x00, 0x00,0x00,0xc0,0x3f, // (-1, 1.5)
0x64,0x00,0x00,0x00, 0x00,0x00,0x20,0xc0, // (100, -2.5)
];
assert_eq!(nbit_decompress(&raw, &cd, 0).unwrap(), expected);
}
// ----- Adversarial / hardening: malformed filter data must not panic -----
#[test]
fn scaleoffset_minbits_64_does_not_panic() {
// minbits == 64 previously overflowed `1u64 << minbits` computing the
// fill code. cd: int, nelmts=1, elem_size=8, fill_defined=1.
let cd = [2u32, 0, 1, 0, 8, 1, 0, 1];
let mut data = Vec::new();
data.extend_from_slice(&64u32.to_le_bytes()); // minbits = 64
data.push(8); // minval_width
data.extend_from_slice(&0i64.to_le_bytes()); // minval
data.extend_from_slice(&[0u8; 8]); // reserved
data.extend_from_slice(&[0u8; 8]); // one 64-bit code
// Must return a Result (Ok or Err) without panicking.
let _ = scaleoffset_decompress(&data, &cd, 8);
}
#[test]
fn scaleoffset_huge_nelmts_bounded_by_chunk_size() {
// minbits == 0 (no packed payload) with a giant nelmts must not try to
// allocate when the expected chunk size is small.
let cd = [2u32, 0, u32::MAX, 0, 4, 0, 0, 0];
let mut data = Vec::new();
data.extend_from_slice(&0u32.to_le_bytes()); // minbits = 0
data.push(4);
data.extend_from_slice(&[0u8; 4]);
assert!(scaleoffset_decompress(&data, &cd, 64).is_err());
}
#[test]
fn nbit_deeply_nested_array_is_rejected() {
// A chain of ARRAY nodes (class 2) far deeper than NBIT_MAX_DEPTH must
// error rather than recurse into a stack overflow. Layout per level:
// [class=2, total]. Terminate with a (never-reached) atomic.
let mut cd = vec![0u32, 0, 1]; // nparms, flag, nelmts
for _ in 0..500 {
cd.push(2); // ARRAY
cd.push(8); // total size
}
cd.extend_from_slice(&[1, 8, 0, 8, 0]); // atomic leaf
assert!(nbit_decompress(&[], &cd, 0).is_err());
}
#[test]
fn nbit_atomic_bit_offset_overflow_is_rejected() {
// bit_offset + precision near u32::MAX must not overflow the check.
let cd = [0u32, 0, 1, 1, 8, 0, u32::MAX, u32::MAX];
assert!(nbit_decompress(&[0u8; 8], &cd, 0).is_err());
}
#[test]
fn nbit_huge_nelmts_bounded_by_chunk_size() {
// Valid 1-byte atomic but an enormous element count; the expected chunk
// size bounds the allocation.
let cd = [0u32, 0, u32::MAX, 1, 1, 0, 8, 0];
assert!(nbit_decompress(&[0u8; 4], &cd, 16).is_err());
}
#[test]
fn scaleoffset_truncated_inputs_do_not_panic() {
assert!(scaleoffset_decompress(&[], &[2, 0, 1, 0, 4, 0, 0, 0], 4).is_err());
assert!(scaleoffset_decompress(&[0u8; 3], &[2, 0, 1, 0, 4, 0, 0, 0], 4).is_err());
// Missing client data entirely.
assert!(scaleoffset_decompress(&[0u8; 32], &[2, 0], 4).is_err());
}
} }
+282 -86
View File
@@ -140,27 +140,36 @@ pub fn read_fixed_array_chunks(
)); ));
} }
// Skip version(1) + client_id(1) + header_address(offset_size) // Elements start immediately after the data block prefix.
let mut pos = db_header_size; let elements_start = db_offset + db_header_size;
// Check if paged let num_elements = header.num_elements as usize;
let page_size = 1u64 << header.max_nelmts_bits; // A chunk index cannot describe more elements than the file has bytes (each
let is_paged = header.num_elements > page_size; // element occupies at least `offset_size` bytes). Reject a corrupt count
// before it can drive a huge loop or overflow an offset computation.
if is_paged { if num_elements > file_data.len() {
// For paged data blocks, we need to handle page bitmap + pages
// For now, implement non-paged path (covers most real-world cases)
return Err(FormatError::ChunkedReadError( return Err(FormatError::ChunkedReadError(
"paged Fixed Array data blocks not yet supported".into(), "Fixed Array element count exceeds file size".into(),
)); ));
} }
// Non-paged: elements stored directly
let num_elements = header.num_elements as usize;
let os = offset_size as usize; let os = offset_size as usize;
// On-disk stride of one element. For non-filtered arrays the element is just
// the chunk address (== offset_size); for filtered arrays it is
// address + chunk_size + filter_mask (== header.element_size).
let elem_stride = (header.element_size as usize).max(os);
// Compute chunk offsets based on index // Absolute file offset of element `idx` within a run starting at `base`,
// Chunks are stored in row-major order within the dataset space // with overflow surfaced as a clean error rather than a panic/wrap.
let elem_at = |base: usize, idx: usize| -> Result<usize, FormatError> {
idx.checked_mul(elem_stride)
.and_then(|o| base.checked_add(o))
.ok_or(FormatError::ChunkedReadError(
"Fixed Array element offset overflow".into(),
))
};
// Compute chunk offsets based on index.
// Chunks are stored in row-major order within the dataset space.
let mut num_chunks_per_dim = Vec::with_capacity(rank); let mut num_chunks_per_dim = Vec::with_capacity(rank);
for d_idx in 0..rank { for d_idx in 0..rank {
let ch_dim = chunk_dimensions[d_idx] as u64; let ch_dim = chunk_dimensions[d_idx] as u64;
@@ -177,97 +186,150 @@ pub fn read_fixed_array_chunks(
chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64; chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64;
let mut chunks = Vec::new(); let mut chunks = Vec::new();
let push_element = |i: usize, abs: usize, chunks: &mut Vec<ChunkInfo>| -> Result<(), FormatError> {
for i in 0..num_elements { if let Some((address, chunk_size, filter_mask)) = parse_fa_element(
let abs_pos = db_offset file_data,
.checked_add(pos) abs,
.ok_or(FormatError::UnexpectedEof { header.client_id,
expected: usize::MAX, offset_size,
available: file_data.len(), header.element_size,
})?; chunk_byte_size,
if abs_pos > file_data.len() { )? {
return Err(FormatError::UnexpectedEof {
expected: abs_pos,
available: file_data.len(),
});
}
let elem_data = &file_data[abs_pos..];
if header.client_id == 0 {
// Non-filtered: just address
if db_offset
.checked_add(pos)
.and_then(|p| p.checked_add(os))
.is_none_or(|end| end > file_data.len())
{
return Err(FormatError::UnexpectedEof {
expected: db_offset.saturating_add(pos).saturating_add(os),
available: file_data.len(),
});
}
let address = read_offset(elem_data, 0, offset_size)?;
pos += os;
if is_undefined(file_data, db_offset + pos - os, offset_size) {
continue; // unallocated chunk
}
let offsets = index_to_chunk_offsets(i, &num_chunks_per_dim, chunk_dimensions); let offsets = index_to_chunk_offsets(i, &num_chunks_per_dim, chunk_dimensions);
chunks.push(ChunkInfo { chunks.push(ChunkInfo {
chunk_size: chunk_byte_size as u32, chunk_size,
filter_mask: 0, filter_mask,
offsets, offsets,
address, address,
}); });
}
Ok(())
};
// A data block is paged when it holds more elements than fit in one page.
// `max_nelmts_bits` is an untrusted u8; a shift >= the pointer width would
// panic, so reject it (real page-size bits are tiny — 10 by default).
if header.max_nelmts_bits as u32 >= usize::BITS {
return Err(FormatError::ChunkedReadError(
"Fixed Array max_nelmts_bits too large".into(),
));
}
let page_nelmts = 1usize << header.max_nelmts_bits;
let is_paged = num_elements > page_nelmts;
if !is_paged {
// Non-paged: prefix, then `num_elements` elements packed directly,
// then a trailing checksum (which we don't validate).
for i in 0..num_elements {
push_element(i, elem_at(elements_start, i)?, &mut chunks)?;
}
return Ok(chunks);
}
// Paged layout: prefix, then a page-init bitmap (one bit per page, MSB-first
// within each byte), then a 4-byte checksum, then the pages. Every page
// occupies a full slot of `page_nelmts` elements plus a 4-byte checksum;
// only the final page holds fewer elements. Uninitialized pages (bit clear)
// still occupy their slot on disk but are zero-filled, so the bitmap — not a
// 0xFF sentinel — is what marks a whole page as unallocated.
let stride_overflow = || {
FormatError::ChunkedReadError("Fixed Array page offset overflow".into())
};
let npages = num_elements.div_ceil(page_nelmts);
let bitmap_size = npages.div_ceil(8);
let bitmap_start = elements_start;
// prefix(db_header_size) + bitmap + checksum(4)
let pages_start = db_offset + db_header_size + bitmap_size + 4;
let page_stride = page_nelmts
.checked_mul(elem_stride)
.and_then(|x| x.checked_add(4))
.ok_or_else(stride_overflow)?;
if bitmap_start + bitmap_size > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: bitmap_start + bitmap_size,
available: file_data.len(),
});
}
for p in 0..npages {
let page_first = p * page_nelmts; // < num_elements, cannot overflow
let page_count = core::cmp::min(page_nelmts, num_elements - page_first);
// Check the page-init bit (MSB-first within each byte).
let bit_byte = file_data[bitmap_start + p / 8];
let bit_mask = 1u8 << (7 - (p % 8));
if bit_byte & bit_mask == 0 {
continue; // entire page unallocated
}
let page_off = p
.checked_mul(page_stride)
.and_then(|o| pages_start.checked_add(o))
.ok_or_else(stride_overflow)?;
for e in 0..page_count {
push_element(page_first + e, elem_at(page_off, e)?, &mut chunks)?;
}
}
Ok(chunks)
}
/// Parse a single Fixed Array element at absolute file offset `abs`.
///
/// Returns `Some((address, chunk_size, filter_mask))` for an allocated chunk, or
/// `None` if the element is undefined (an unallocated chunk, address all-`0xFF`).
fn parse_fa_element(
file_data: &[u8],
abs: usize,
client_id: u8,
offset_size: u8,
element_size: u8,
chunk_byte_size: u64,
) -> Result<Option<(u64, u32, u32)>, FormatError> {
let os = offset_size as usize;
if client_id == 0 {
// Non-filtered: element is just the chunk address.
if abs + os > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: abs + os,
available: file_data.len(),
});
}
if is_undefined(file_data, abs, offset_size) {
return Ok(None);
}
let address = read_offset(file_data, abs, offset_size)?;
Ok(Some((address, chunk_byte_size as u32, 0)))
} else { } else {
// Filtered: address(offset_size) + chunk_size(variable) + filter_mask(4) // Filtered: address(offset_size) + chunk_size(variable) + filter_mask(4)
let es = header.element_size as usize; let es = element_size as usize;
if es < os + 4 { if es < os + 4 {
return Err(FormatError::ChunkedReadError( return Err(FormatError::ChunkedReadError(
"element_size too small for filtered element".into(), "element_size too small for filtered element".into(),
)); ));
} }
let chunk_size_bytes = es - os - 4; let chunk_size_bytes = es - os - 4;
let elem_total = os + chunk_size_bytes + 4; if abs + es > file_data.len() {
if db_offset
.checked_add(pos)
.and_then(|p| p.checked_add(elem_total))
.is_none_or(|end| end > file_data.len())
{
return Err(FormatError::UnexpectedEof { return Err(FormatError::UnexpectedEof {
expected: db_offset.saturating_add(pos).saturating_add(elem_total), expected: abs + es,
available: file_data.len(), available: file_data.len(),
}); });
} }
if is_undefined(file_data, abs, offset_size) {
let address = read_offset(elem_data, 0, offset_size)?; return Ok(None);
}
// Read chunk_size (variable length, little-endian) let address = read_offset(file_data, abs, offset_size)?;
let chunk_size = read_variable_length(&elem_data[os..], chunk_size_bytes)?; let chunk_size = read_variable_length(&file_data[abs + os..], chunk_size_bytes)?;
let fm_off = abs + os + chunk_size_bytes;
let fm_off = os + chunk_size_bytes;
let filter_mask = u32::from_le_bytes([ let filter_mask = u32::from_le_bytes([
elem_data[fm_off], file_data[fm_off],
elem_data[fm_off + 1], file_data[fm_off + 1],
elem_data[fm_off + 2], file_data[fm_off + 2],
elem_data[fm_off + 3], file_data[fm_off + 3],
]); ]);
pos += elem_total; Ok(Some((address, chunk_size as u32, filter_mask)))
if is_undefined(file_data, db_offset + pos - elem_total, offset_size) {
continue; // unallocated chunk
} }
let offsets = index_to_chunk_offsets(i, &num_chunks_per_dim, chunk_dimensions);
chunks.push(ChunkInfo {
chunk_size: chunk_size as u32,
filter_mask,
offsets,
address,
});
}
}
Ok(chunks)
} }
/// Convert a linear chunk index to N-dimensional chunk offsets in dataset space. /// Convert a linear chunk index to N-dimensional chunk offsets in dataset space.
@@ -392,6 +454,41 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
} }
/// Malformed headers must error, never panic (shift overflow, huge counts).
#[test]
fn read_rejects_oversized_max_nelmts_bits() {
let mut buf = vec![0u8; 512];
let fahd = 0x40usize;
buf[fahd..fahd + 4].copy_from_slice(b"FAHD");
buf[fahd + 4] = 0; // version
buf[fahd + 5] = 0; // client_id
buf[fahd + 6] = 8; // element_size
buf[fahd + 7] = 200; // max_nelmts_bits — absurd, would overflow a shift
buf[fahd + 8..fahd + 16].copy_from_slice(&3u64.to_le_bytes()); // num_elements
buf[fahd + 16..fahd + 24].copy_from_slice(&0x100u64.to_le_bytes());
// FADB so parsing reaches the paged check
let db = 0x100usize;
buf[db..db + 4].copy_from_slice(b"FADB");
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
assert!(r.is_err());
}
#[test]
fn read_rejects_num_elements_larger_than_file() {
let mut buf = vec![0u8; 256];
let fahd = 0x40usize;
buf[fahd..fahd + 4].copy_from_slice(b"FAHD");
buf[fahd + 6] = 8;
buf[fahd + 7] = 10;
buf[fahd + 8..fahd + 16].copy_from_slice(&u64::MAX.to_le_bytes()); // absurd count
buf[fahd + 16..fahd + 24].copy_from_slice(&0x80u64.to_le_bytes());
buf[0x80..0x84].copy_from_slice(b"FADB");
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
assert!(r.is_err());
}
#[test] #[test]
fn parse_fixed_array_header_invalid_version() { fn parse_fixed_array_header_invalid_version() {
let mut buf = vec![0u8; 256]; let mut buf = vec![0u8; 256];
@@ -535,4 +632,103 @@ mod tests {
assert_eq!(chunks[2].address, 0x3000); assert_eq!(chunks[2].address, 0x3000);
assert_eq!(chunks[2].chunk_size, 100); assert_eq!(chunks[2].chunk_size, 100);
} }
/// Build a synthetic *paged* Fixed Array (non-filtered) and verify reading.
///
/// Layout reverse-engineered and confirmed against an HDF5 2.0 file:
/// after the FADB prefix comes a page-init bitmap (MSB-first within each
/// byte), a 4-byte checksum, then full-size page slots (`page_nelmts`
/// elements + a 4-byte checksum each), with only the last page shorter.
/// Uninitialized pages occupy their slot but are skipped via the bitmap.
#[test]
fn read_paged_non_filtered_chunks() {
let offset_size: u8 = 8;
let length_size: u8 = 8;
let os = offset_size as usize;
// page_nelmts = 1 << 2 = 4. Use 11 elements => 3 pages
// (page0: 4, page1: 4, page2: 3 short). Initialize pages 0 and 2; leave
// page 1 uninitialized. 3 pages still fits one bitmap byte, but we place
// the set bits at positions 7 and 5 to lock the MSB-first ordering.
let max_nelmts_bits = 2u8;
let page_nelmts = 1usize << max_nelmts_bits; // 4
let num_elements = 11u64;
let db_header_size = 4 + 1 + 1 + os; // FADB sig+ver+client+header_addr
let bitmap_size = 1usize; // ceil(3/8)
let page_total = page_nelmts * os + 4; // elements + checksum
let fahd_offset = 0x100usize;
let db_offset = 0x400usize;
let mut file_data = vec![0u8; 0x4000];
// FAHD
file_data[fahd_offset..fahd_offset + 4].copy_from_slice(b"FAHD");
file_data[fahd_offset + 4] = 0; // version
file_data[fahd_offset + 5] = 0; // client_id = non-filtered
file_data[fahd_offset + 6] = os as u8; // element_size = address only
file_data[fahd_offset + 7] = max_nelmts_bits;
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_elements.to_le_bytes());
file_data[fahd_offset + 16..fahd_offset + 24]
.copy_from_slice(&(db_offset as u64).to_le_bytes());
// FADB prefix
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
file_data[db_offset + 4] = 0; // version
file_data[db_offset + 5] = 0; // client_id
file_data[db_offset + 6..db_offset + 6 + os]
.copy_from_slice(&(fahd_offset as u64).to_le_bytes());
// Page-init bitmap: pages 0 and 2 initialized, page 1 not.
// MSB-first => page0 -> bit7 (0x80), page2 -> bit5 (0x20) => 0xA0.
let bitmap_off = db_offset + db_header_size;
file_data[bitmap_off] = 0b1010_0000;
// Pages start after bitmap + 4-byte checksum.
let pages_start = db_offset + db_header_size + bitmap_size + 4;
let base_addr = 0x1000u64;
// Page 0 (elements 0..4) and page 2 (elements 8..11) carry addresses;
// page 1's slot is left zero-filled and must be skipped.
for &p in &[0usize, 2usize] {
let page_off = pages_start + p * page_total;
let count = core::cmp::min(page_nelmts, num_elements as usize - p * page_nelmts);
for e in 0..count {
let i = p * page_nelmts + e;
let addr = base_addr + i as u64 * 0x100;
let pos = page_off + e * os;
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes());
}
}
let header =
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
assert_eq!(header.num_elements, 11);
let ds_dims = vec![11u64 * 20];
let chunk_dims = vec![20u32];
let chunks = read_fixed_array_chunks(
&file_data,
&header,
&ds_dims,
&chunk_dims,
8,
offset_size,
length_size,
)
.unwrap();
// Page 1 (elements 4,5,6,7) is uninitialized => skipped. The remaining
// 7 chunks (0..4 and 8..11) come back with their original linear index.
assert_eq!(chunks.len(), 7);
let mut got: Vec<(u64, u64)> = chunks
.iter()
.map(|c| (c.offsets[0], c.address))
.collect();
got.sort();
let expect: Vec<(u64, u64)> = [0usize, 1, 2, 3, 8, 9, 10]
.iter()
.map(|&i| (i as u64 * 20, base_addr + i as u64 * 0x100))
.collect();
assert_eq!(got, expect);
}
} }
+22 -2
View File
@@ -379,8 +379,9 @@ impl FractalHeapHeader {
// Build table of (block_size, heap_offset) for each child entry // Build table of (block_size, heap_offset) for each child entry
let mut current_heap_offset = iblock_heap_offset; let mut current_heap_offset = iblock_heap_offset;
// Count direct block entries vs indirect block entries // Rows below max_direct_rows hold direct blocks; rows at/above hold
let start_indirect = self.starting_row_of_indirect_blocks as usize; // child indirect blocks. (NOT the FRHP "starting rows" field.)
let start_indirect = self.max_direct_rows();
// Read child addresses for direct block rows // Read child addresses for direct block rows
let max_direct_rows = nrows_usize.min(start_indirect); let max_direct_rows = nrows_usize.min(start_indirect);
@@ -455,6 +456,25 @@ impl FractalHeapHeader {
}) })
} }
/// Number of rows in the doubling table whose block size is at most the
/// maximum *direct* block size. Rows below this hold direct blocks; rows at
/// or above it hold child indirect blocks.
///
/// This is derived from the heap geometry, NOT the FRHP
/// "Starting # of Rows in Root Indirect Block" field (a constant, often 1)
/// — confusing the two makes a multi-direct-block heap unreadable.
fn max_direct_rows(&self) -> usize {
if self.starting_block_size == 0 {
return usize::MAX;
}
// Rows 0 and 1 share the starting block size; row r (r >= 1) is
// starting_block_size * 2^(r-1). The largest direct row reaches
// max_direct_block_size, giving log2(max/start) + 2 direct rows.
let ratio = (self.max_direct_block_size / self.starting_block_size).max(1);
let log2 = 63 - ratio.leading_zeros() as usize;
log2 + 2
}
/// Get block size for a given row in the doubling table. /// Get block size for a given row in the doubling table.
fn block_size_for_row(&self, row: usize) -> u64 { fn block_size_for_row(&self, row: usize) -> u64 {
let sbs = self.starting_block_size; let sbs = self.starting_block_size;
+427
View File
@@ -19,6 +19,8 @@ use alloc::{vec, vec::Vec};
use core::ops::Range; use core::ops::Range;
use crate::error::FormatError;
/// A selection describing which elements of a dataset to access. /// A selection describing which elements of a dataset to access.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum Selection { pub enum Selection {
@@ -220,6 +222,262 @@ impl Selection {
} }
} }
} }
/// Decode a selection from its on-disk **`H5S_select_serialize`** form.
///
/// Returns the selection and the number of bytes consumed (selections are
/// self-describing in length, so the count lets a caller walk a packed list
/// of selections — as the Virtual Dataset global-heap block does).
///
/// Only the forms needed for VDS assembly are decoded: `ALL`, `NONE`, and
/// **regular** hyperslabs serialized at **version 3** (the encoding HDF5
/// 1.10+/2.0 emit). Point selections, irregular hyperslabs, and older
/// hyperslab versions return an error rather than mis-decoding.
pub fn decode_serialized(data: &[u8]) -> Result<(Selection, usize), FormatError> {
if data.len() < 8 {
return Err(FormatError::UnexpectedEof {
expected: 8,
available: data.len(),
});
}
let sel_type = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
match sel_type {
// ALL / NONE: type(4) + version(4) + reserved(4) + length(4) = 16 bytes.
3 | 0 => {
if data.len() < 16 {
return Err(FormatError::UnexpectedEof {
expected: 16,
available: data.len(),
});
}
let sel = if sel_type == 3 {
Selection::All
} else {
Selection::None
};
Ok((sel, 16))
}
2 => decode_hyperslab_serialized(data, version),
1 => Err(FormatError::ChunkedReadError(
"VDS point selections are not supported".into(),
)),
_ => Err(FormatError::ChunkedReadError(
"unknown dataspace selection type".into(),
)),
}
}
/// Enumerate the selected element indices of a **1-D** dataspace of the
/// given `extent`, in row-major selection order.
///
/// Convenience wrapper over [`Selection::iter_linear`] for rank-1 spaces.
pub fn iter_linear_1d(&self, extent: u64) -> Result<Vec<u64>, FormatError> {
self.iter_linear(&[extent])
}
/// Enumerate the **row-major linear indices** of the selected elements of a
/// dataspace with shape `dims`, in row-major (C) iteration order.
///
/// This is the order HDF5 uses to pair a virtual selection with a source
/// selection in a Virtual Dataset, so the i-th index returned here for the
/// virtual selection corresponds to the i-th index for the source
/// selection. Hyperslab/point selections whose rank differs from
/// `dims.len()` are rejected.
pub fn iter_linear(&self, dims: &[u64]) -> Result<Vec<u64>, FormatError> {
let overflow = || FormatError::Overflow("VDS selection index overflow".into());
let total: u64 = dims
.iter()
.try_fold(1u64, |acc, &d| acc.checked_mul(d))
.ok_or_else(overflow)?;
// Row-major strides: row_stride[d] = product(dims[d+1..]).
let rank = dims.len();
let mut row_stride = vec![1u64; rank];
for d in (0..rank.saturating_sub(1)).rev() {
row_stride[d] = row_stride[d + 1]
.checked_mul(dims[d + 1])
.ok_or_else(overflow)?;
}
match self {
Selection::All => Ok((0..total).collect()),
Selection::None => Ok(Vec::new()),
Selection::Hyperslab {
start,
stride,
count,
block,
} => {
if start.len() != rank {
return Err(FormatError::ChunkedReadError(
"VDS selection rank does not match dataspace rank".into(),
));
}
// Selected coordinates along each dimension, in order.
let mut per_dim: Vec<Vec<u64>> = Vec::with_capacity(rank);
for d in 0..rank {
let mut coords = Vec::new();
for ci in 0..count[d] {
let base = ci
.checked_mul(stride[d])
.and_then(|o| start[d].checked_add(o))
.ok_or_else(overflow)?;
for bi in 0..block[d] {
let coord = base.checked_add(bi).ok_or_else(overflow)?;
// Anything past the extent is malformed; bail before the
// coordinate list can grow without bound.
if coord >= dims[d] {
return Err(FormatError::ChunkedReadError(
"VDS hyperslab selection exceeds dataspace extent".into(),
));
}
coords.push(coord);
}
}
per_dim.push(coords);
}
if per_dim.iter().any(|c| c.is_empty()) {
return Ok(Vec::new());
}
// Cartesian product in row-major order (dim 0 slowest-varying).
let out_len: usize = per_dim
.iter()
.try_fold(1usize, |acc, c| acc.checked_mul(c.len()))
.ok_or_else(overflow)?;
let mut out = Vec::with_capacity(out_len);
let mut idx = vec![0usize; rank];
loop {
let mut lin = 0u64;
for d in 0..rank {
lin = per_dim[d][idx[d]]
.checked_mul(row_stride[d])
.and_then(|o| lin.checked_add(o))
.ok_or_else(overflow)?;
}
out.push(lin);
// Increment the mixed-radix counter, last dimension fastest.
let mut carry = true;
for d in (0..rank).rev() {
idx[d] += 1;
if idx[d] < per_dim[d].len() {
carry = false;
break;
}
idx[d] = 0;
}
if carry {
break;
}
}
Ok(out)
}
Selection::Points(pts) => {
let mut out = Vec::with_capacity(pts.len());
for p in pts {
if p.len() != rank {
return Err(FormatError::ChunkedReadError(
"VDS point selection rank does not match dataspace rank".into(),
));
}
let mut lin = 0u64;
for d in 0..rank {
if p[d] >= dims[d] {
return Err(FormatError::ChunkedReadError(
"VDS point selection exceeds dataspace extent".into(),
));
}
lin = p[d]
.checked_mul(row_stride[d])
.and_then(|o| lin.checked_add(o))
.ok_or_else(overflow)?;
}
out.push(lin);
}
Ok(out)
}
}
}
}
/// Decode an `H5S_SEL_HYPER` selection in its serialized form. Only version-3
/// **regular** hyperslabs are supported.
fn decode_hyperslab_serialized(
data: &[u8],
version: u32,
) -> Result<(Selection, usize), FormatError> {
if version != 3 {
return Err(FormatError::ChunkedReadError(
"only version-3 hyperslab selections are supported".into(),
));
}
// type(4) ver(4) flags(1) enc_size(1) rank(4) [start,stride,count,block]*rank
if data.len() < 14 {
return Err(FormatError::UnexpectedEof {
expected: 14,
available: data.len(),
});
}
let flags = data[8];
let enc_size = data[9] as usize;
// Bit 0 set => regular hyperslab. Irregular hyperslabs list explicit blocks.
if flags & 0x01 == 0 {
return Err(FormatError::ChunkedReadError(
"irregular VDS hyperslab selections are not supported".into(),
));
}
if enc_size != 2 && enc_size != 4 && enc_size != 8 {
return Err(FormatError::ChunkedReadError(
"unsupported hyperslab coordinate encoding size".into(),
));
}
let rank = u32::from_le_bytes([data[10], data[11], data[12], data[13]]) as usize;
// HDF5 caps dataspace rank at 32 (H5S_MAX_RANK). Reject anything larger so a
// corrupt rank can't drive a huge allocation or read loop.
if rank > 32 {
return Err(FormatError::ChunkedReadError(
"hyperslab selection rank exceeds maximum (32)".into(),
));
}
let mut pos = 14;
let read_coord = |data: &[u8], pos: usize| -> Result<u64, FormatError> {
if pos + enc_size > data.len() {
return Err(FormatError::UnexpectedEof {
expected: pos + enc_size,
available: data.len(),
});
}
let mut v = 0u64;
for (i, &b) in data[pos..pos + enc_size].iter().enumerate() {
v |= (b as u64) << (i * 8);
}
Ok(v)
};
let (mut start, mut stride, mut count, mut block) = (
Vec::with_capacity(rank),
Vec::with_capacity(rank),
Vec::with_capacity(rank),
Vec::with_capacity(rank),
);
for _ in 0..rank {
start.push(read_coord(data, pos)?);
pos += enc_size;
stride.push(read_coord(data, pos)?);
pos += enc_size;
count.push(read_coord(data, pos)?);
pos += enc_size;
block.push(read_coord(data, pos)?);
pos += enc_size;
}
Ok((
Selection::Hyperslab {
start,
stride,
count,
block,
},
pos,
))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -313,4 +571,173 @@ mod tests {
// Chunk [9..10] should not intersect (only row 9, but selection ends at row 8) // Chunk [9..10] should not intersect (only row 9, but selection ends at row 8)
assert!(!sel.intersects_chunk(&[9], &[1])); assert!(!sel.intersects_chunk(&[9], &[1]));
} }
#[test]
fn decode_all_selection_16_bytes() {
let bytes = [3u8, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
let (sel, consumed) = Selection::decode_serialized(&bytes).unwrap();
assert_eq!(sel, Selection::All);
assert_eq!(consumed, 16);
assert_eq!(sel.iter_linear_1d(4).unwrap(), vec![0, 1, 2, 3]);
}
#[test]
fn decode_regular_hyperslab_matches_vds_fixture() {
// Exact virtual selection for src_a in the VDS fixture:
// start=0 stride=1 count=1 block=4, version 3, enc_size 2, rank 1.
let bytes = [
0x02, 0, 0, 0, // type = HYPER
0x03, 0, 0, 0, // version 3
0x01, // flags = regular
0x02, // enc_size = 2
0x01, 0, 0, 0, // rank = 1
0x00, 0x00, // start
0x01, 0x00, // stride
0x01, 0x00, // count
0x04, 0x00, // block
];
let (sel, consumed) = Selection::decode_serialized(&bytes).unwrap();
assert_eq!(consumed, 22);
assert_eq!(
sel,
Selection::Hyperslab {
start: vec![0],
stride: vec![1],
count: vec![1],
block: vec![4],
}
);
assert_eq!(sel.iter_linear_1d(8).unwrap(), vec![0, 1, 2, 3]);
}
#[test]
fn decode_hyperslab_start4() {
let bytes = [
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, //
0x04, 0x00, 0x01, 0x00, 0x01, 0x00, 0x04, 0x00,
];
let (sel, _) = Selection::decode_serialized(&bytes).unwrap();
assert_eq!(sel.iter_linear_1d(8).unwrap(), vec![4, 5, 6, 7]);
}
#[test]
fn decode_strided_hyperslab_iter() {
// start=1 stride=3 count=2 block=2 => 1,2, 4,5
let bytes = [
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, //
0x01, 0x00, 0x03, 0x00, 0x02, 0x00, 0x02, 0x00,
];
let (sel, _) = Selection::decode_serialized(&bytes).unwrap();
assert_eq!(sel.iter_linear_1d(8).unwrap(), vec![1, 2, 4, 5]);
}
#[test]
fn decode_nd_hyperslab_iter_rejected() {
let bytes = [
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x02, 0, 0, 0, // rank 2
0, 0, 1, 0, 1, 0, 2, 0, 0, 0, 1, 0, 1, 0, 2, 0,
];
let (sel, _) = Selection::decode_serialized(&bytes).unwrap();
assert!(sel.iter_linear_1d(16).is_err());
}
#[test]
fn decode_irregular_hyperslab_rejected() {
let bytes = [0x02u8, 0, 0, 0, 0x03, 0, 0, 0, 0x00, 0x02, 0x01, 0, 0, 0];
assert!(Selection::decode_serialized(&bytes).is_err());
}
#[test]
fn iter_linear_2d_block_row_major() {
// A 2x2 block at the top-left of a 4x4 space => linear 0,1,4,5.
let sel = Selection::Hyperslab {
start: vec![0, 0],
stride: vec![1, 1],
count: vec![1, 1],
block: vec![2, 2],
};
assert_eq!(sel.iter_linear(&[4, 4]).unwrap(), vec![0, 1, 4, 5]);
// The same block shifted to the bottom-right => 10,11,14,15.
let sel2 = Selection::Hyperslab {
start: vec![2, 2],
stride: vec![1, 1],
count: vec![1, 1],
block: vec![2, 2],
};
assert_eq!(sel2.iter_linear(&[4, 4]).unwrap(), vec![10, 11, 14, 15]);
}
#[test]
fn iter_linear_2d_strided() {
// start=(0,0) stride=(2,2) count=(2,2) block=(1,1) over 4x4 =>
// coords (0,0)(0,2)(2,0)(2,2) => linear 0,2,8,10.
let sel = Selection::Hyperslab {
start: vec![0, 0],
stride: vec![2, 2],
count: vec![2, 2],
block: vec![1, 1],
};
assert_eq!(sel.iter_linear(&[4, 4]).unwrap(), vec![0, 2, 8, 10]);
}
#[test]
fn iter_linear_all_2d() {
assert_eq!(
Selection::All.iter_linear(&[2, 3]).unwrap(),
(0..6).collect::<Vec<_>>()
);
}
#[test]
fn iter_linear_rank_mismatch_rejected() {
let sel = Selection::Hyperslab {
start: vec![0],
stride: vec![1],
count: vec![1],
block: vec![2],
};
assert!(sel.iter_linear(&[4, 4]).is_err());
}
// ----- Adversarial / hardening: malformed input must error, never panic -----
#[test]
fn decode_all_truncated_does_not_overrun() {
// ALL claims to consume 16 bytes but only 8 are present.
let bytes = [3u8, 0, 0, 0, 1, 0, 0, 0];
assert!(Selection::decode_serialized(&bytes).is_err());
}
#[test]
fn decode_hyperslab_huge_rank_rejected() {
// rank = 0xFFFFFFFF must not drive a giant allocation.
let bytes = [
0x02u8, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0xFF, 0xFF, 0xFF, 0xFF,
];
assert!(Selection::decode_serialized(&bytes).is_err());
}
#[test]
fn iter_linear_hyperslab_overflow_is_error() {
// start/stride/count near u64::MAX must not panic on multiply/add.
let sel = Selection::Hyperslab {
start: vec![u64::MAX - 1],
stride: vec![u64::MAX],
count: vec![u64::MAX],
block: vec![u64::MAX],
};
assert!(sel.iter_linear(&[100]).is_err());
}
#[test]
fn iter_linear_dims_product_overflow_is_error() {
assert!(Selection::All.iter_linear(&[u64::MAX, u64::MAX]).is_err());
}
#[test]
fn decode_empty_or_short_is_error_not_panic() {
assert!(Selection::decode_serialized(&[]).is_err());
assert!(Selection::decode_serialized(&[2, 0, 0, 0, 3, 0]).is_err());
}
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -662,6 +662,156 @@ fn v4_fixed_array_read() {
} }
} }
#[test]
fn v4_virtual_dataset_same_file_read() {
// A 1-D virtual dataset assembled from two same-file sources:
// virt[0:4] <- src_a[1:5] (partial source hyperslab) => 11,12,13,14
// virt[4:8] <- (unmapped) => fill 0
// virt[8:12] <- src_b[0:4] (ALL) => 20,21,22,23
let file_data = include_bytes!("fixtures/vds_same_file.h5");
let (raw, datatype, _) = read_chunked_dataset(file_data, "virt");
let values = read_as_i32(&raw, &datatype).unwrap();
assert_eq!(
values,
vec![11, 12, 13, 14, 0, 0, 0, 0, 20, 21, 22, 23],
"VDS assembly (partial source slice + fill gap) mismatch"
);
}
#[test]
fn v4_virtual_dataset_2d_same_file_read() {
// A 4x4 virtual dataset assembled from two 2x2 same-file sources placed as
// non-contiguous blocks (exercises N-dimensional row-major scatter):
// virt[0:2,0:2] <- src_a = [[1,2],[3,4]]
// virt[2:4,2:4] <- src_b = [[5,6],[7,8]]
// everything else -> fill 0
let file_data = include_bytes!("fixtures/vds_2d_same_file.h5");
let (raw, datatype, _) = read_chunked_dataset(file_data, "virt");
let values = read_as_i32(&raw, &datatype).unwrap();
assert_eq!(
values,
vec![1, 2, 0, 0, 3, 4, 0, 0, 0, 0, 5, 6, 0, 0, 7, 8],
"2-D VDS block scatter mismatch"
);
}
#[test]
fn scaleoffset_float_escale_reads_as_raw() {
// The scale-offset filter's floating-point *E-scale* mode (cd_values[0] = 1)
// is not actually implemented by the HDF5 library: when asked for it, HDF5
// stores the chunk raw (no minbits/minval header) and sets the chunk filter
// mask to skip the filter. So such a dataset must read back verbatim purely
// by honoring the per-chunk filter mask — no E-scale decoder is needed.
let file_data = include_bytes!("fixtures/scaleoffset_float_escale.h5");
let (raw, datatype, _) = read_chunked_dataset(file_data, "x");
let values = read_as_f64(&raw, &datatype).unwrap();
let expect: Vec<f64> = (0..20).map(|i| i as f64 * 0.25).collect();
assert_eq!(values, expect, "E-scale (raw + masked filter) must read verbatim");
}
#[test]
fn v4_virtual_dataset_cycle_errors_not_overflow() {
// virt -> virt2 -> virt (both virtual, same file). The reader must reject
// the nested virtual source rather than recurse into a stack overflow.
let file_data = include_bytes!("fixtures/vds_cyclic.h5");
let offset = find_signature(file_data).unwrap();
let sb = Superblock::parse(file_data, offset).unwrap();
let addr = resolve_path_any(file_data, &sb, "virt").unwrap();
let hdr = ObjectHeader::parse(file_data, addr as usize, sb.offset_size, sb.length_size).unwrap();
let ds = Dataspace::parse(
&hdr.messages.iter().find(|m| m.msg_type == MessageType::Dataspace).unwrap().data,
sb.length_size,
)
.unwrap();
let (dt, _) = Datatype::parse(
&hdr.messages.iter().find(|m| m.msg_type == MessageType::Datatype).unwrap().data,
)
.unwrap();
let layout = DataLayout::parse(
&hdr.messages.iter().find(|m| m.msg_type == MessageType::DataLayout).unwrap().data,
sb.offset_size,
sb.length_size,
)
.unwrap();
let r = read_raw_data_full(
file_data, &layout, &ds, &dt, None, sb.offset_size, sb.length_size,
);
assert!(r.is_err(), "cyclic virtual dataset must error, not overflow");
}
#[test]
fn v4_virtual_dataset_external_file_read() {
use clawhdf5_format::data_read::read_raw_data_full_with_resolver;
// The virtual file maps virt[0:8] <- (external) ext_src.h5:/data = [10..17].
let virt = include_bytes!("fixtures/vds_external_virt.h5");
let src = include_bytes!("fixtures/vds_external_src.h5").to_vec();
let sig = find_signature(virt).unwrap();
let sb = Superblock::parse(virt, sig).unwrap();
let addr = resolve_path_any(virt, &sb, "virt").unwrap();
let hdr = ObjectHeader::parse(virt, addr as usize, sb.offset_size, sb.length_size).unwrap();
let ds = Dataspace::parse(
&hdr.messages.iter().find(|m| m.msg_type == MessageType::Dataspace).unwrap().data,
sb.length_size,
)
.unwrap();
let (dt, _) = Datatype::parse(
&hdr.messages.iter().find(|m| m.msg_type == MessageType::Datatype).unwrap().data,
)
.unwrap();
let layout = DataLayout::parse(
&hdr.messages.iter().find(|m| m.msg_type == MessageType::DataLayout).unwrap().data,
sb.offset_size,
sb.length_size,
)
.unwrap();
// Resolver supplies the external source file's bytes by its stored name.
let resolver = |name: &str| -> Option<Vec<u8>> {
if name == "ext_src.h5" {
Some(src.clone())
} else {
None
}
};
let raw = read_raw_data_full_with_resolver(
virt,
&layout,
&ds,
&dt,
None,
sb.offset_size,
sb.length_size,
Some(&resolver),
)
.unwrap();
let values = read_as_i32(&raw, &dt).unwrap();
assert_eq!(values, vec![10, 11, 12, 13, 14, 15, 16, 17]);
// With no resolver, an external source is a clean error (not wrong data).
let no_resolver = read_raw_data_full_with_resolver(
virt, &layout, &ds, &dt, None, sb.offset_size, sb.length_size, None,
);
assert!(no_resolver.is_err());
}
#[test]
fn v4_paged_fixed_array_read() {
// 1025 chunks of 16 int32s, gzip-filtered => Fixed Array index whose data
// block is *paged* (page holds 1024 elements). Page 0 is full, page 1 holds
// the single trailing chunk. Chunk k stores value k at its first element.
let file_data = include_bytes!("fixtures/v4_fixed_array_paged.h5");
let (raw, datatype, _) = read_chunked_dataset(file_data, "big");
let values = read_as_i32(&raw, &datatype).unwrap();
assert_eq!(values.len(), 1025 * 16);
for k in 0..1025usize {
assert_eq!(values[k * 16], k as i32, "chunk-start mismatch at chunk {k}");
for j in 1..16 {
assert_eq!(values[k * 16 + j], 0, "non-start element nonzero at {}", k * 16 + j);
}
}
}
#[test] #[test]
fn v4_2d_fixed_array_read() { fn v4_2d_fixed_array_read() {
let file_data = include_bytes!("fixtures/v4_2d.h5"); let file_data = include_bytes!("fixtures/v4_2d.h5");
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-gpu # clawhdf5-gpu
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-gpu.svg)](https://crates.io/crates/rustyhdf5-gpu) [![crates.io](https://img.shields.io/crates/v/clawhdf5-gpu.svg)](https://crates.io/crates/clawhdf5-gpu)
[![docs.rs](https://docs.rs/rustyhdf5-gpu/badge.svg)](https://docs.rs/rustyhdf5-gpu) [![docs.rs](https://docs.rs/clawhdf5-gpu/badge.svg)](https://docs.rs/clawhdf5-gpu)
GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders. GPU-accelerated vector operations for clawhdf5 using wgpu compute shaders.
## Features ## Features
@@ -14,7 +14,7 @@ GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders.
## Usage ## Usage
```rust ```rust
use rustyhdf5_gpu::GpuAccelerator; use clawhdf5_gpu::GpuAccelerator;
let accel = GpuAccelerator::new().unwrap(); let accel = GpuAccelerator::new().unwrap();
let distances = accel.l2_distances(&query, &vectors).unwrap(); let distances = accel.l2_distances(&query, &vectors).unwrap();
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-io # clawhdf5-io
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-io.svg)](https://crates.io/crates/rustyhdf5-io) [![crates.io](https://img.shields.io/crates/v/clawhdf5-io.svg)](https://crates.io/crates/clawhdf5-io)
[![docs.rs](https://docs.rs/rustyhdf5-io/badge.svg)](https://docs.rs/rustyhdf5-io) [![docs.rs](https://docs.rs/clawhdf5-io/badge.svg)](https://docs.rs/clawhdf5-io)
I/O abstraction layer for rustyhdf5. I/O abstraction layer for clawhdf5.
## Features ## Features
@@ -15,7 +15,7 @@ I/O abstraction layer for rustyhdf5.
## Usage ## Usage
```rust ```rust
use rustyhdf5_io::MmapReader; use clawhdf5_io::MmapReader;
let reader = MmapReader::open("data.h5").unwrap(); let reader = MmapReader::open("data.h5").unwrap();
``` ```
+6 -6
View File
@@ -1,22 +1,22 @@
# edgehdf5-migrate # clawhdf5-migrate
[![crates.io](https://img.shields.io/crates/v/edgehdf5-migrate.svg)](https://crates.io/crates/edgehdf5-migrate) [![crates.io](https://img.shields.io/crates/v/clawhdf5-migrate.svg)](https://crates.io/crates/clawhdf5-migrate)
[![docs.rs](https://img.shields.io/docsrs/edgehdf5-migrate)](https://docs.rs/edgehdf5-migrate) [![docs.rs](https://img.shields.io/docsrs/clawhdf5-migrate)](https://docs.rs/clawhdf5-migrate)
CLI tool to migrate SQLite agent memory databases to HDF5 format. CLI tool to migrate SQLite agent memory databases to HDF5 format.
Converts existing SQLite-based agent memory stores (embeddings, text chunks, metadata) into the HDF5 format used by [edgehdf5-memory](https://crates.io/crates/edgehdf5-memory). Converts existing SQLite-based agent memory stores (embeddings, text chunks, metadata) into the HDF5 format used by [clawhdf5-agent](https://crates.io/crates/clawhdf5-agent).
## Installation ## Installation
```bash ```bash
cargo install edgehdf5-migrate cargo install clawhdf5-migrate
``` ```
## Usage ## Usage
```bash ```bash
edgehdf5-migrate --input agent.db --output agent.h5 clawhdf5-migrate --input agent.db --output agent.h5
``` ```
## License ## License
+159
View File
@@ -0,0 +1,159 @@
//! Read a migration HDF5 file back into the in-memory data model.
//!
//! Used to verify migrated content (real validation) and to merge new rows into
//! an existing output (incremental migration). Mirrors the layout produced by
//! [`crate::hdf5_writer`].
use clawhdf5::reader::{File, Group};
use clawhdf5_format::type_builders::AttrValue;
use crate::sqlite_reader::{Entity, MemoryChunk, Relation, Session, SqliteData};
type BoxErr = Box<dyn std::error::Error>;
fn read_strings(group: &Group<'_>, name: &str) -> Result<Vec<String>, BoxErr> {
Ok(group.dataset(name)?.read_string()?)
}
fn read_i64s(group: &Group<'_>, name: &str) -> Result<Vec<i64>, BoxErr> {
Ok(group.dataset(name)?.read_i64()?)
}
fn read_f64s(group: &Group<'_>, name: &str) -> Result<Vec<f64>, BoxErr> {
Ok(group.dataset(name)?.read_f64()?)
}
/// Read the embeddings dataset as a flat `Vec<f32>` of `n * dim` values,
/// handling both f32 and (lossy) f16 storage.
fn read_embeddings_flat(group: &Group<'_>) -> Result<Vec<f32>, BoxErr> {
Ok(group.dataset("embeddings")?.read_f32()?)
}
/// Read a migration HDF5 file into a [`SqliteData`].
pub fn read_hdf5(path: &str) -> Result<SqliteData, BoxErr> {
let file = File::open(path)?;
let embedding_dim = match file.root().attrs()?.get("embedding_dim") {
Some(AttrValue::I64(d)) => *d as usize,
_ => 0,
};
let chunks = read_chunks(&file, embedding_dim)?;
let sessions = read_sessions(&file)?;
let entities = read_entities(&file)?;
let relations = read_relations(&file)?;
Ok(SqliteData {
chunks,
sessions,
entities,
relations,
embedding_dim,
})
}
fn read_chunks(file: &File, dim: usize) -> Result<Vec<MemoryChunk>, BoxErr> {
let g = file.group("chunks")?;
let count = group_count(&g)?;
if count == 0 {
return Ok(Vec::new());
}
let ids = read_i64s(&g, "id")?;
let texts = read_strings(&g, "text")?;
let channels = read_strings(&g, "source_channel")?;
let timestamps = read_f64s(&g, "timestamp")?;
let session_ids = read_strings(&g, "session_id")?;
let tags = read_strings(&g, "tags")?;
let deleted = g.dataset("deleted")?.read_i32()?;
let emb_flat = read_embeddings_flat(&g)?;
let dim = dim.max(1);
let mut chunks = Vec::with_capacity(ids.len());
for (i, &id) in ids.iter().enumerate() {
let embedding = emb_flat
.get(i * dim..(i + 1) * dim)
.map(|s| s.to_vec())
.unwrap_or_default();
chunks.push(MemoryChunk {
id,
chunk: texts.get(i).cloned().unwrap_or_default(),
embedding,
source_channel: channels.get(i).cloned().unwrap_or_default(),
timestamp: timestamps.get(i).copied().unwrap_or(0.0),
session_id: session_ids.get(i).cloned().unwrap_or_default(),
tags: tags.get(i).cloned().unwrap_or_default(),
deleted: deleted.get(i).copied().unwrap_or(0),
});
}
Ok(chunks)
}
fn read_sessions(file: &File) -> Result<Vec<Session>, BoxErr> {
let g = file.group("sessions")?;
if group_count(&g)? == 0 {
return Ok(Vec::new());
}
let ids = read_strings(&g, "id")?;
let starts = read_i64s(&g, "start_idx")?;
let ends = read_i64s(&g, "end_idx")?;
let channels = read_strings(&g, "channel")?;
let timestamps = read_f64s(&g, "timestamp")?;
let summaries = read_strings(&g, "summary")?;
Ok((0..ids.len())
.map(|i| Session {
id: ids[i].clone(),
start_idx: starts.get(i).copied().unwrap_or(0),
end_idx: ends.get(i).copied().unwrap_or(0),
channel: channels.get(i).cloned().unwrap_or_default(),
timestamp: timestamps.get(i).copied().unwrap_or(0.0),
summary: summaries.get(i).cloned().unwrap_or_default(),
})
.collect())
}
fn read_entities(file: &File) -> Result<Vec<Entity>, BoxErr> {
let g = file.group("entities")?;
if group_count(&g)? == 0 {
return Ok(Vec::new());
}
let ids = read_i64s(&g, "id")?;
let names = read_strings(&g, "name")?;
let types = read_strings(&g, "type")?;
let emb_idxs = read_i64s(&g, "embedding_idx")?;
Ok((0..ids.len())
.map(|i| Entity {
id: ids[i],
name: names.get(i).cloned().unwrap_or_default(),
entity_type: types.get(i).cloned().unwrap_or_default(),
embedding_idx: emb_idxs.get(i).copied().unwrap_or(-1),
})
.collect())
}
fn read_relations(file: &File) -> Result<Vec<Relation>, BoxErr> {
let g = file.group("relations")?;
if group_count(&g)? == 0 {
return Ok(Vec::new());
}
let srcs = read_i64s(&g, "src")?;
let tgts = read_i64s(&g, "tgt")?;
let rels = read_strings(&g, "relation")?;
let weights = read_f64s(&g, "weight")?;
let timestamps = read_f64s(&g, "timestamp")?;
Ok((0..srcs.len())
.map(|i| Relation {
src: srcs[i],
tgt: tgts.get(i).copied().unwrap_or(0),
relation: rels.get(i).cloned().unwrap_or_default(),
weight: weights.get(i).copied().unwrap_or(1.0),
timestamp: timestamps.get(i).copied().unwrap_or(0.0),
})
.collect())
}
fn group_count(group: &Group<'_>) -> Result<u64, BoxErr> {
match group.attrs()?.get("count") {
Some(AttrValue::I64(n)) => Ok(*n as u64),
_ => Ok(0),
}
}
+259 -69
View File
@@ -1,9 +1,12 @@
mod hdf5_reader;
mod hdf5_writer; mod hdf5_writer;
mod sqlite_reader; mod sqlite_reader;
mod validate; mod validate;
use clap::Parser; use clap::Parser;
use sqlite_reader::SchemaConfig;
/// Migrate ZeroClaw agent memory from SQLite to HDF5 format. /// Migrate ZeroClaw agent memory from SQLite to HDF5 format.
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "clawhdf5-migrate", version, about)] #[command(name = "clawhdf5-migrate", version, about)]
@@ -48,45 +51,126 @@ struct Cli {
#[arg(long)] #[arg(long)]
dry_run: bool, dry_run: bool,
/// Content-check every migrated row (default: a representative sample)
#[arg(long)]
validate_full: bool,
/// Append only rows newer than the existing output (by chunk id), merging
/// into the file at --hdf5 if it exists
#[arg(long)]
incremental: bool,
/// Override the SQLite table name for memory chunks
#[arg(long)]
chunks_table: Option<String>,
/// Override the SQLite table name for sessions
#[arg(long)]
sessions_table: Option<String>,
/// Override the SQLite table name for entities
#[arg(long)]
entities_table: Option<String>,
/// Override the SQLite table name for relations
#[arg(long)]
relations_table: Option<String>,
/// Print progress /// Print progress
#[arg(long)] #[arg(long)]
verbose: bool, verbose: bool,
} }
/// Build the schema config from CLI table-name overrides (defaults otherwise).
fn schema_from_cli(cli: &Cli) -> SchemaConfig {
let mut c = SchemaConfig::default();
if let Some(t) = &cli.chunks_table {
c.chunks.table = t.clone();
}
if let Some(t) = &cli.sessions_table {
c.sessions.table = t.clone();
}
if let Some(t) = &cli.entities_table {
c.entities.table = t.clone();
}
if let Some(t) = &cli.relations_table {
c.relations.table = t.clone();
}
c
}
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse(); let cli = Cli::parse();
let schema = schema_from_cli(&cli);
// Dry run: a fast count-only pass that does not buffer the database.
if cli.dry_run {
let counts = sqlite_reader::read_counts(&cli.sqlite, cli.skip_deleted, &schema)?;
eprintln!("Dry run — no output file written.");
eprintln!(
"Would migrate: {} chunks, {} sessions, {} entities, {} relations",
counts.chunks, counts.sessions, counts.entities, counts.relations
);
return Ok(());
}
if cli.verbose { if cli.verbose {
eprintln!("Reading SQLite database: {}", cli.sqlite); eprintln!("Reading SQLite database: {}", cli.sqlite);
} }
let data = sqlite_reader::read_sqlite(&cli.sqlite, cli.skip_deleted, cli.embedding_dim)?; // Incremental: merge new rows into the existing output (if present).
let incremental_base = if cli.incremental && std::path::Path::new(&cli.hdf5).exists() {
Some(hdf5_reader::read_hdf5(&cli.hdf5)?)
} else {
None
};
let min_chunk_id = incremental_base
.as_ref()
.map(|d| d.chunks.iter().map(|c| c.id).max().unwrap_or(0))
.unwrap_or(0);
let dim_hint = cli
.embedding_dim
.or_else(|| incremental_base.as_ref().map(|d| d.embedding_dim));
let source = if min_chunk_id > 0 {
sqlite_reader::read_sqlite_filtered(
&cli.sqlite,
cli.skip_deleted,
dim_hint,
&schema,
min_chunk_id,
)?
} else {
sqlite_reader::read_sqlite(&cli.sqlite, cli.skip_deleted, dim_hint, &schema)?
};
// Build the dataset to write: either the source alone, or the existing
// output plus the newly-read rows (metadata groups refreshed from source).
let data = match incremental_base {
Some(mut base) => {
let added = source.chunks.len();
base.chunks.extend(source.chunks);
base.sessions = source.sessions;
base.entities = source.entities;
base.relations = source.relations;
base.embedding_dim = source.embedding_dim.max(base.embedding_dim);
if cli.verbose {
eprintln!("Incremental: appended {added} new chunks (id > {min_chunk_id})");
}
base
}
None => source,
};
if cli.verbose { if cli.verbose {
eprintln!( eprintln!(
"Read {} chunks, {} sessions, {} entities, {} relations", "Migrating {} chunks, {} sessions, {} entities, {} relations (dim={})",
data.chunks.len(),
data.sessions.len(),
data.entities.len(),
data.relations.len()
);
eprintln!("Embedding dimension: {}", data.embedding_dim);
}
if cli.dry_run {
eprintln!("Dry run — no output file written.");
eprintln!(
"Would migrate: {} chunks, {} sessions, {} entities, {} relations (dim={})",
data.chunks.len(), data.chunks.len(),
data.sessions.len(), data.sessions.len(),
data.entities.len(), data.entities.len(),
data.relations.len(), data.relations.len(),
data.embedding_dim data.embedding_dim
); );
return Ok(());
}
if cli.verbose {
eprintln!("Writing HDF5 file: {}", cli.hdf5); eprintln!("Writing HDF5 file: {}", cli.hdf5);
} }
@@ -101,25 +185,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
hdf5_writer::write_hdf5(&cli.hdf5, &data, &opts)?; hdf5_writer::write_hdf5(&cli.hdf5, &data, &opts)?;
if cli.verbose { if cli.verbose {
eprintln!("Validating output..."); eprintln!("Validating output (content check)...");
} }
let summary = validate::validate_hdf5( let summary = validate::validate_hdf5(&cli.hdf5, &data, cli.validate_full, cli.float16)?;
&cli.hdf5,
data.chunks.len(),
data.sessions.len(),
data.entities.len(),
data.relations.len(),
data.embedding_dim,
)?;
eprintln!( eprintln!(
"Migration complete: {} chunks, {} sessions, {} entities, {} relations (dim={})", "Migration complete: {} chunks, {} sessions, {} entities, {} relations (dim={}); {} rows content-verified",
summary.chunks, summary.chunks,
summary.sessions, summary.sessions,
summary.entities, summary.entities,
summary.relations, summary.relations,
summary.embedding_dim summary.embedding_dim,
summary.rows_checked,
); );
Ok(()) Ok(())
@@ -231,7 +309,7 @@ mod tests {
insert_relation(&conn, 1, 1, "self"); insert_relation(&conn, 1, 1, "self");
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions { let opts = hdf5_writer::WriteOptions {
agent_id: "test-agent".into(), agent_id: "test-agent".into(),
embedder: "test-embed".into(), embedder: "test-embed".into(),
@@ -241,7 +319,7 @@ mod tests {
}; };
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap(); hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 2, 1, 1, 1, 8).unwrap(); let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 2); assert_eq!(summary.chunks, 2);
assert_eq!(summary.sessions, 1); assert_eq!(summary.sessions, 1);
assert_eq!(summary.entities, 1); assert_eq!(summary.entities, 1);
@@ -262,7 +340,7 @@ mod tests {
insert_chunk(&conn, 3, "also active", &make_embedding(4, 3.0), 0); insert_chunk(&conn, 3, "also active", &make_embedding(4, 3.0), 0);
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, true, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, true, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 2); assert_eq!(data.chunks.len(), 2);
let opts = hdf5_writer::WriteOptions { let opts = hdf5_writer::WriteOptions {
@@ -274,7 +352,7 @@ mod tests {
}; };
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap(); hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 2, 0, 0, 0, 4).unwrap(); let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 2); assert_eq!(summary.chunks, 2);
} }
@@ -289,7 +367,7 @@ mod tests {
insert_chunk(&conn, 2, "deleted", &make_embedding(4, 2.0), 1); insert_chunk(&conn, 2, "deleted", &make_embedding(4, 2.0), 1);
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 2); assert_eq!(data.chunks.len(), 2);
} }
@@ -303,7 +381,7 @@ mod tests {
insert_chunk(&conn, 1, "test", &make_embedding(16, 0.5), 0); insert_chunk(&conn, 1, "test", &make_embedding(16, 0.5), 0);
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.embedding_dim, 16); assert_eq!(data.embedding_dim, 16);
} }
@@ -317,7 +395,7 @@ mod tests {
insert_chunk(&conn, 1, "test", &make_embedding(16, 0.5), 0); insert_chunk(&conn, 1, "test", &make_embedding(16, 0.5), 0);
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, Some(8)).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, Some(8), &SchemaConfig::default()).unwrap();
assert_eq!(data.embedding_dim, 8); assert_eq!(data.embedding_dim, 8);
// Embedding truncated to dim 8 // Embedding truncated to dim 8
assert_eq!(data.chunks[0].embedding.len(), 8); assert_eq!(data.chunks[0].embedding.len(), 8);
@@ -335,7 +413,7 @@ mod tests {
insert_chunk(&conn, 1, "test", &emb, 0); insert_chunk(&conn, 1, "test", &emb, 0);
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions { let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(), agent_id: "t".into(),
embedder: "t".into(), embedder: "t".into(),
@@ -345,8 +423,8 @@ mod tests {
}; };
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap(); hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// Verify file was created and is valid // Content-validate with the float16 tolerance enabled.
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 1, 0, 0, 0, 4).unwrap(); let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), &data, true, true).unwrap();
assert_eq!(summary.chunks, 1); assert_eq!(summary.chunks, 1);
// Verify float16 values are within tolerance // Verify float16 values are within tolerance
@@ -375,7 +453,7 @@ mod tests {
} }
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts_compressed = hdf5_writer::WriteOptions { let opts_compressed = hdf5_writer::WriteOptions {
agent_id: "t".into(), agent_id: "t".into(),
@@ -415,7 +493,7 @@ mod tests {
drop(conn); drop(conn);
// Simulate dry-run: read data but don't write // Simulate dry-run: read data but don't write
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 1); assert_eq!(data.chunks.len(), 1);
assert!(!h5_path.exists()); assert!(!h5_path.exists());
} }
@@ -427,7 +505,7 @@ mod tests {
let db_path = create_test_db(&dir); let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5"); let h5_path = dir.path().join("out.h5");
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 0); assert_eq!(data.chunks.len(), 0);
assert_eq!(data.sessions.len(), 0); assert_eq!(data.sessions.len(), 0);
assert_eq!(data.entities.len(), 0); assert_eq!(data.entities.len(), 0);
@@ -442,7 +520,7 @@ mod tests {
}; };
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap(); hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 0, 0, 0, 0, 0).unwrap(); let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 0); assert_eq!(summary.chunks, 0);
} }
@@ -465,7 +543,7 @@ mod tests {
} }
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 1000); assert_eq!(data.chunks.len(), 1000);
let opts = hdf5_writer::WriteOptions { let opts = hdf5_writer::WriteOptions {
@@ -478,7 +556,7 @@ mod tests {
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap(); hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), 1000, 0, 0, 0, 64).unwrap(); validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 1000); assert_eq!(summary.chunks, 1000);
} }
@@ -495,7 +573,7 @@ mod tests {
insert_session(&conn, "session-gamma", 21, 30); insert_session(&conn, "session-gamma", 21, 30);
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.sessions.len(), 3); assert_eq!(data.sessions.len(), 3);
let opts = hdf5_writer::WriteOptions { let opts = hdf5_writer::WriteOptions {
@@ -507,7 +585,7 @@ mod tests {
}; };
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap(); hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 0, 3, 0, 0, 0).unwrap(); let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.sessions, 3); assert_eq!(summary.sessions, 3);
} }
@@ -527,7 +605,7 @@ mod tests {
insert_relation(&conn, 2, 3, "uses"); insert_relation(&conn, 2, 3, "uses");
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.entities.len(), 3); assert_eq!(data.entities.len(), 3);
assert_eq!(data.relations.len(), 3); assert_eq!(data.relations.len(), 3);
@@ -540,7 +618,7 @@ mod tests {
}; };
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap(); hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 0, 0, 3, 3, 0).unwrap(); let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.entities, 3); assert_eq!(summary.entities, 3);
assert_eq!(summary.relations, 3); assert_eq!(summary.relations, 3);
} }
@@ -556,7 +634,7 @@ mod tests {
insert_chunk(&conn, 1, "test", &make_embedding(4, 1.0), 0); insert_chunk(&conn, 1, "test", &make_embedding(4, 1.0), 0);
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions { let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(), agent_id: "t".into(),
embedder: "t".into(), embedder: "t".into(),
@@ -566,15 +644,15 @@ mod tests {
}; };
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap(); hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// Expect 5 chunks but only 1 was written // Validating against a source with an extra (unwritten) chunk must fail.
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), 5, 0, 0, 0, 4); let mut bigger = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default())
.unwrap();
let mut extra = bigger.chunks[0].clone();
extra.id = 999;
bigger.chunks.push(extra);
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), &bigger, false, false);
assert!(result.is_err()); assert!(result.is_err());
assert!( assert!(result.unwrap_err().to_string().contains("count mismatch"));
result
.unwrap_err()
.to_string()
.contains("Chunk count mismatch")
);
} }
// ---------- Test 14: Metadata attributes are stored ---------- // ---------- Test 14: Metadata attributes are stored ----------
@@ -588,7 +666,7 @@ mod tests {
insert_chunk(&conn, 1, "test", &make_embedding(8, 1.0), 0); insert_chunk(&conn, 1, "test", &make_embedding(8, 1.0), 0);
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions { let opts = hdf5_writer::WriteOptions {
agent_id: "my-agent-42".into(), agent_id: "my-agent-42".into(),
embedder: "openai-ada".into(), embedder: "openai-ada".into(),
@@ -634,7 +712,7 @@ mod tests {
insert_chunk(&conn, 1, "test", &emb, 0); insert_chunk(&conn, 1, "test", &emb, 0);
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions { let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(), agent_id: "t".into(),
embedder: "t".into(), embedder: "t".into(),
@@ -680,7 +758,7 @@ mod tests {
drop(conn); drop(conn);
// Skip deleted // Skip deleted
let data = sqlite_reader::read_sqlite(&db_path, true, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, true, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 4); // chunk 3 is deleted assert_eq!(data.chunks.len(), 4); // chunk 3 is deleted
let opts = hdf5_writer::WriteOptions { let opts = hdf5_writer::WriteOptions {
@@ -692,7 +770,7 @@ mod tests {
}; };
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap(); hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 4, 2, 2, 1, 16).unwrap(); let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 4); assert_eq!(summary.chunks, 4);
assert_eq!(summary.sessions, 2); assert_eq!(summary.sessions, 2);
assert_eq!(summary.entities, 2); assert_eq!(summary.entities, 2);
@@ -711,7 +789,7 @@ mod tests {
insert_session(&conn, "s1", 0, 10); insert_session(&conn, "s1", 0, 10);
drop(conn); drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap(); let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions { let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(), agent_id: "t".into(),
embedder: "t".into(), embedder: "t".into(),
@@ -721,13 +799,125 @@ mod tests {
}; };
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap(); hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), 0, 99, 0, 0, 0); // Validating against a source whose session content differs must fail.
let mut tampered = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default())
.unwrap();
tampered.sessions[0].summary = "DIFFERENT".into();
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), &tampered, false, false);
assert!(result.is_err()); assert!(result.is_err());
assert!( assert!(result.unwrap_err().to_string().contains("session"));
result }
.unwrap_err()
.to_string() // ---------- Real content validation catches corrupt embeddings ----------
.contains("Session count mismatch") #[test]
fn test_content_validation_catches_embedding_corruption() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "hello", &make_embedding(8, 1.0), 0);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// A source whose embedding differs (but counts match) must fail validation.
let mut tampered = sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default())
.unwrap();
tampered.chunks[0].embedding[3] += 9.0;
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), &tampered, true, false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("embedding"));
}
// ---------- Configurable schema: custom table names ----------
#[test]
fn test_configurable_table_names() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("custom.db");
let path_str = db_path.to_str().unwrap().to_string();
let conn = Connection::open(&path_str).unwrap();
// Chunks live in a differently-named table; the others use defaults.
conn.execute_batch(
"CREATE TABLE my_chunks (
id INTEGER PRIMARY KEY, chunk TEXT, embedding BLOB,
source_channel TEXT, timestamp REAL, session_id TEXT, tags TEXT, deleted INTEGER
); );
CREATE TABLE sessions (id TEXT, start_idx INTEGER, end_idx INTEGER, channel TEXT, timestamp REAL, summary TEXT);
CREATE TABLE entities (id INTEGER, name TEXT, type TEXT, embedding_idx INTEGER);
CREATE TABLE relations (src INTEGER, tgt INTEGER, relation TEXT, weight REAL, timestamp REAL);",
)
.unwrap();
let blob: Vec<u8> = make_embedding(4, 1.0).iter().flat_map(|v| v.to_le_bytes()).collect();
conn.execute(
"INSERT INTO my_chunks VALUES (1, 'hi', ?1, 'api', 1.0, 's', '', 0)",
rusqlite::params![blob],
)
.unwrap();
drop(conn);
let mut schema = SchemaConfig::default();
schema.chunks.table = "my_chunks".into();
let data = sqlite_reader::read_sqlite(&path_str, false, None, &schema).unwrap();
assert_eq!(data.chunks.len(), 1);
assert_eq!(data.chunks[0].chunk, "hi");
assert_eq!(data.embedding_dim, 4);
// Counts pass should also honor the custom table name.
let counts = sqlite_reader::read_counts(&path_str, false, &schema).unwrap();
assert_eq!(counts.chunks, 1);
}
// ---------- Incremental migration appends only new rows ----------
#[test]
fn test_incremental_migration() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let cfg = SchemaConfig::default();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
// First migration: 2 chunks.
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "one", &make_embedding(4, 1.0), 0);
insert_chunk(&conn, 2, "two", &make_embedding(4, 2.0), 0);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None, &cfg).unwrap();
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// Add two more rows, then migrate incrementally.
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 3, "three", &make_embedding(4, 3.0), 0);
insert_chunk(&conn, 4, "four", &make_embedding(4, 4.0), 0);
drop(conn);
let base = hdf5_reader::read_hdf5(h5_path.to_str().unwrap()).unwrap();
let max_id = base.chunks.iter().map(|c| c.id).max().unwrap_or(0);
assert_eq!(max_id, 2);
let new = sqlite_reader::read_sqlite_filtered(&db_path, false, Some(4), &cfg, max_id).unwrap();
assert_eq!(new.chunks.len(), 2); // only id 3 and 4
let mut merged = base;
merged.chunks.extend(new.chunks);
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &merged, &opts).unwrap();
let final_data = hdf5_reader::read_hdf5(h5_path.to_str().unwrap()).unwrap();
assert_eq!(final_data.chunks.len(), 4);
let texts: Vec<&str> = final_data.chunks.iter().map(|c| c.chunk.as_str()).collect();
assert_eq!(texts, vec!["one", "two", "three", "four"]);
} }
} }
+148 -20
View File
@@ -53,9 +53,115 @@ pub struct SqliteData {
pub embedding_dim: usize, pub embedding_dim: usize,
} }
/// A table name plus the ordered column names the reader maps by position.
#[derive(Debug, Clone)]
pub struct TableSchema {
pub table: String,
pub columns: Vec<&'static str>,
}
/// Configurable mapping from a SQLite layout to the migration's data model.
///
/// Defaults to the ZeroClaw schema; the CLI can override the table names so the
/// tool can migrate databases whose tables are named differently. Column names
/// (and order) are part of the config too, so a library caller can remap them.
#[derive(Debug, Clone)]
pub struct SchemaConfig {
pub chunks: TableSchema,
pub sessions: TableSchema,
pub entities: TableSchema,
pub relations: TableSchema,
}
impl Default for SchemaConfig {
fn default() -> Self {
SchemaConfig {
chunks: TableSchema {
table: "memory_chunks".into(),
columns: vec![
"id",
"chunk",
"embedding",
"source_channel",
"timestamp",
"session_id",
"tags",
"deleted",
],
},
sessions: TableSchema {
table: "sessions".into(),
columns: vec!["id", "start_idx", "end_idx", "channel", "timestamp", "summary"],
},
entities: TableSchema {
table: "entities".into(),
columns: vec!["id", "name", "type", "embedding_idx"],
},
relations: TableSchema {
table: "relations".into(),
columns: vec!["src", "tgt", "relation", "weight", "timestamp"],
},
}
}
}
impl TableSchema {
fn select(&self, where_clause: &str) -> String {
format!(
"SELECT {} FROM {}{}",
self.columns.join(", "),
self.table,
where_clause
)
}
}
/// Row counts for each table — a fast pass that does not load row contents.
/// Used for `--dry-run` and progress without buffering the whole database.
#[derive(Debug, Default, Clone, Copy)]
pub struct RowCounts {
pub chunks: u64,
pub sessions: u64,
pub entities: u64,
pub relations: u64,
}
fn count_rows(conn: &Connection, table: &str, where_clause: &str) -> SqlResult<u64> {
conn.query_row(
&format!("SELECT COUNT(*) FROM {table}{where_clause}"),
[],
|r| r.get(0),
)
}
/// Count rows in each table without reading their contents.
pub fn read_counts(
path: &str,
skip_deleted: bool,
config: &SchemaConfig,
) -> Result<RowCounts, Box<dyn std::error::Error>> {
let conn = Connection::open(path)?;
let deleted_col = config.chunks.columns.get(7).copied().unwrap_or("deleted");
let chunk_where = if skip_deleted {
format!(" WHERE {deleted_col} = 0")
} else {
String::new()
};
Ok(RowCounts {
chunks: count_rows(&conn, &config.chunks.table, &chunk_where)?,
sessions: count_rows(&conn, &config.sessions.table, "")?,
entities: count_rows(&conn, &config.entities.table, "")?,
relations: count_rows(&conn, &config.relations.table, "")?,
})
}
/// Auto-detect embedding dimension from the first chunk's BLOB size. /// Auto-detect embedding dimension from the first chunk's BLOB size.
fn detect_embedding_dim(conn: &Connection) -> SqlResult<Option<usize>> { fn detect_embedding_dim(conn: &Connection, config: &SchemaConfig) -> SqlResult<Option<usize>> {
let mut stmt = conn.prepare("SELECT embedding FROM memory_chunks LIMIT 1")?; let emb_col = config.chunks.columns.get(2).copied().unwrap_or("embedding");
let mut stmt = conn.prepare(&format!(
"SELECT {emb_col} FROM {} LIMIT 1",
config.chunks.table
))?;
let mut rows = stmt.query([])?; let mut rows = stmt.query([])?;
if let Some(row) = rows.next()? { if let Some(row) = rows.next()? {
let blob: Vec<u8> = row.get(0)?; let blob: Vec<u8> = row.get(0)?;
@@ -80,18 +186,31 @@ pub fn read_sqlite(
path: &str, path: &str,
skip_deleted: bool, skip_deleted: bool,
embedding_dim: Option<usize>, embedding_dim: Option<usize>,
config: &SchemaConfig,
) -> Result<SqliteData, Box<dyn std::error::Error>> {
read_sqlite_filtered(path, skip_deleted, embedding_dim, config, 0)
}
/// Like [`read_sqlite`] but only reads chunks whose id is greater than
/// `min_chunk_id` (0 = all). Used for incremental migration.
pub fn read_sqlite_filtered(
path: &str,
skip_deleted: bool,
embedding_dim: Option<usize>,
config: &SchemaConfig,
min_chunk_id: i64,
) -> Result<SqliteData, Box<dyn std::error::Error>> { ) -> Result<SqliteData, Box<dyn std::error::Error>> {
let conn = Connection::open(path)?; let conn = Connection::open(path)?;
let dim = match embedding_dim { let dim = match embedding_dim {
Some(d) => d, Some(d) => d,
None => detect_embedding_dim(&conn)?.unwrap_or(0), None => detect_embedding_dim(&conn, config)?.unwrap_or(0),
}; };
let chunks = read_chunks(&conn, skip_deleted, dim)?; let chunks = read_chunks(&conn, skip_deleted, dim, config, min_chunk_id)?;
let sessions = read_sessions(&conn)?; let sessions = read_sessions(&conn, config)?;
let entities = read_entities(&conn)?; let entities = read_entities(&conn, config)?;
let relations = read_relations(&conn)?; let relations = read_relations(&conn, config)?;
Ok(SqliteData { Ok(SqliteData {
chunks, chunks,
@@ -106,16 +225,26 @@ fn read_chunks(
conn: &Connection, conn: &Connection,
skip_deleted: bool, skip_deleted: bool,
expected_dim: usize, expected_dim: usize,
config: &SchemaConfig,
min_chunk_id: i64,
) -> SqlResult<Vec<MemoryChunk>> { ) -> SqlResult<Vec<MemoryChunk>> {
let sql = if skip_deleted { let id_col = config.chunks.columns.first().copied().unwrap_or("id");
"SELECT id, chunk, embedding, source_channel, timestamp, session_id, tags, deleted \ let deleted_col = config.chunks.columns.get(7).copied().unwrap_or("deleted");
FROM memory_chunks WHERE deleted = 0" let mut conds = Vec::new();
if skip_deleted {
conds.push(format!("{deleted_col} = 0"));
}
if min_chunk_id > 0 {
conds.push(format!("{id_col} > {min_chunk_id}"));
}
let where_clause = if conds.is_empty() {
String::new()
} else { } else {
"SELECT id, chunk, embedding, source_channel, timestamp, session_id, tags, deleted \ format!(" WHERE {}", conds.join(" AND "))
FROM memory_chunks"
}; };
let sql = config.chunks.select(&where_clause);
let mut stmt = conn.prepare(sql)?; let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {
let blob: Vec<u8> = row.get(2)?; let blob: Vec<u8> = row.get(2)?;
let mut embedding = blob_to_f32(&blob); let mut embedding = blob_to_f32(&blob);
@@ -140,9 +269,8 @@ fn read_chunks(
rows.collect() rows.collect()
} }
fn read_sessions(conn: &Connection) -> SqlResult<Vec<Session>> { fn read_sessions(conn: &Connection, config: &SchemaConfig) -> SqlResult<Vec<Session>> {
let mut stmt = let mut stmt = conn.prepare(&config.sessions.select(""))?;
conn.prepare("SELECT id, start_idx, end_idx, channel, timestamp, summary FROM sessions")?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {
Ok(Session { Ok(Session {
id: row.get(0)?, id: row.get(0)?,
@@ -156,8 +284,8 @@ fn read_sessions(conn: &Connection) -> SqlResult<Vec<Session>> {
rows.collect() rows.collect()
} }
fn read_entities(conn: &Connection) -> SqlResult<Vec<Entity>> { fn read_entities(conn: &Connection, config: &SchemaConfig) -> SqlResult<Vec<Entity>> {
let mut stmt = conn.prepare("SELECT id, name, type, embedding_idx FROM entities")?; let mut stmt = conn.prepare(&config.entities.select(""))?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {
Ok(Entity { Ok(Entity {
id: row.get(0)?, id: row.get(0)?,
@@ -169,8 +297,8 @@ fn read_entities(conn: &Connection) -> SqlResult<Vec<Entity>> {
rows.collect() rows.collect()
} }
fn read_relations(conn: &Connection) -> SqlResult<Vec<Relation>> { fn read_relations(conn: &Connection, config: &SchemaConfig) -> SqlResult<Vec<Relation>> {
let mut stmt = conn.prepare("SELECT src, tgt, relation, weight, timestamp FROM relations")?; let mut stmt = conn.prepare(&config.relations.select(""))?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {
Ok(Relation { Ok(Relation {
src: row.get(0)?, src: row.get(0)?,
+122 -90
View File
@@ -1,5 +1,7 @@
use clawhdf5::reader::File; use crate::hdf5_reader::read_hdf5;
use clawhdf5_format::type_builders::AttrValue; use crate::sqlite_reader::SqliteData;
type BoxErr = Box<dyn std::error::Error>;
/// Summary of a migration validation. /// Summary of a migration validation.
#[derive(Debug)] #[derive(Debug)]
@@ -9,119 +11,149 @@ pub struct ValidationSummary {
pub entities: u64, pub entities: u64,
pub relations: u64, pub relations: u64,
pub embedding_dim: u64, pub embedding_dim: u64,
/// Number of rows whose full content was compared against the source.
pub rows_checked: u64,
} }
/// Validate an HDF5 file written by the migration tool. /// Validate a migrated HDF5 file against the source data.
/// ///
/// Checks that row counts and embedding dimensions match expectations. /// Reads the written file back and compares actual content — chunk text,
/// embeddings, and every session/entity/relation field — to the source, not
/// just the row counts. When `full` is false a representative sample of chunk
/// rows is content-checked (counts and all other groups are always checked in
/// full); when `full` is true every chunk row is compared too. `float16` widens
/// the embedding tolerance to allow for half-precision quantization.
pub fn validate_hdf5( pub fn validate_hdf5(
path: &str, path: &str,
expected_chunks: usize, source: &SqliteData,
expected_sessions: usize, full: bool,
expected_entities: usize, float16: bool,
expected_relations: usize, ) -> Result<ValidationSummary, BoxErr> {
expected_dim: usize, let got = read_hdf5(path)?;
) -> Result<ValidationSummary, Box<dyn std::error::Error>> {
let file = File::open(path)?;
let root = file.root();
// Read root attributes // ---- Counts ----
let attrs = root.attrs()?; check_count("chunk", got.chunks.len(), source.chunks.len())?;
let stored_dim = match attrs.get("embedding_dim") { check_count("session", got.sessions.len(), source.sessions.len())?;
Some(AttrValue::I64(d)) => *d as u64, check_count("entity", got.entities.len(), source.entities.len())?;
_ => 0, check_count("relation", got.relations.len(), source.relations.len())?;
}; if got.embedding_dim != source.embedding_dim {
// Validate chunks group
let chunks_group = file.group("chunks")?;
let chunk_attrs = chunks_group.attrs()?;
let chunk_count = match chunk_attrs.get("count") {
Some(AttrValue::I64(n)) => *n as u64,
_ => 0,
};
if chunk_count != expected_chunks as u64 {
return Err(format!( return Err(format!(
"Chunk count mismatch: HDF5 has {}, expected {}", "embedding_dim mismatch: HDF5 has {}, source has {}",
chunk_count, expected_chunks got.embedding_dim, source.embedding_dim
) )
.into()); .into());
} }
// Validate embedding dimensions if chunks exist // ---- Chunk content (sampled or full) ----
if chunk_count > 0 && expected_dim > 0 { let (emb_abs, emb_rel) = if float16 { (1e-2, 1e-2) } else { (1e-4, 0.0) };
let emb_ds = chunks_group.dataset("embeddings")?; let mut rows_checked = 0u64;
let shape = emb_ds.shape()?; for i in sample_indices(source.chunks.len(), full) {
if shape.len() == 2 && shape[1] != expected_dim as u64 { let (s, g) = (&source.chunks[i], &got.chunks[i]);
if s.id != g.id {
return Err(field_err("chunk", i, "id", s.id, g.id));
}
if s.chunk != g.chunk {
return Err(format!( return Err(format!(
"Embedding dim mismatch: HDF5 has {}, expected {}", "chunk[{i}].text mismatch: source {:?}, HDF5 {:?}",
shape[1], expected_dim truncate(&s.chunk),
truncate(&g.chunk)
) )
.into()); .into());
} }
if s.session_id != g.session_id || s.source_channel != g.source_channel || s.tags != g.tags
if stored_dim != expected_dim as u64 { {
return Err(format!("chunk[{i}] string field mismatch").into());
}
if s.deleted != g.deleted {
return Err(field_err("chunk", i, "deleted", s.deleted, g.deleted));
}
if s.embedding.len() != g.embedding.len() {
return Err(format!( return Err(format!(
"Embedding dim attr mismatch: HDF5 attr={}, expected {}", "chunk[{i}] embedding length mismatch: {} vs {}",
stored_dim, expected_dim s.embedding.len(),
g.embedding.len()
)
.into());
}
for (k, (&a, &b)) in s.embedding.iter().zip(g.embedding.iter()).enumerate() {
if (a - b).abs() > emb_abs + emb_rel * a.abs() {
return Err(format!(
"chunk[{i}].embedding[{k}] mismatch: source {a}, HDF5 {b}"
) )
.into()); .into());
} }
} }
rows_checked += 1;
// Validate sessions group
let sessions_group = file.group("sessions")?;
let sess_attrs = sessions_group.attrs()?;
let session_count = match sess_attrs.get("count") {
Some(AttrValue::I64(n)) => *n as u64,
_ => 0,
};
if session_count != expected_sessions as u64 {
return Err(format!(
"Session count mismatch: HDF5 has {}, expected {}",
session_count, expected_sessions
)
.into());
} }
// Validate entities group // ---- Other groups (always full — they are small) ----
let entities_group = file.group("entities")?; for (i, (s, g)) in source.sessions.iter().zip(got.sessions.iter()).enumerate() {
let ent_attrs = entities_group.attrs()?; if s.id != g.id
let entity_count = match ent_attrs.get("count") { || s.start_idx != g.start_idx
Some(AttrValue::I64(n)) => *n as u64, || s.end_idx != g.end_idx
_ => 0, || s.channel != g.channel
}; || s.summary != g.summary
{
if entity_count != expected_entities as u64 { return Err(format!("session[{i}] mismatch").into());
return Err(format!(
"Entity count mismatch: HDF5 has {}, expected {}",
entity_count, expected_entities
)
.into());
} }
rows_checked += 1;
// Validate relations group }
let relations_group = file.group("relations")?; for (i, (s, g)) in source.entities.iter().zip(got.entities.iter()).enumerate() {
let rel_attrs = relations_group.attrs()?; if s.id != g.id
let relation_count = match rel_attrs.get("count") { || s.name != g.name
Some(AttrValue::I64(n)) => *n as u64, || s.entity_type != g.entity_type
_ => 0, || s.embedding_idx != g.embedding_idx
}; {
return Err(format!("entity[{i}] mismatch").into());
if relation_count != expected_relations as u64 { }
return Err(format!( rows_checked += 1;
"Relation count mismatch: HDF5 has {}, expected {}", }
relation_count, expected_relations for (i, (s, g)) in source.relations.iter().zip(got.relations.iter()).enumerate() {
) if s.src != g.src || s.tgt != g.tgt || s.relation != g.relation {
.into()); return Err(format!("relation[{i}] mismatch").into());
}
rows_checked += 1;
} }
Ok(ValidationSummary { Ok(ValidationSummary {
chunks: chunk_count, chunks: got.chunks.len() as u64,
sessions: session_count, sessions: got.sessions.len() as u64,
entities: entity_count, entities: got.entities.len() as u64,
relations: relation_count, relations: got.relations.len() as u64,
embedding_dim: stored_dim, embedding_dim: got.embedding_dim as u64,
rows_checked,
}) })
} }
fn check_count(kind: &str, got: usize, expected: usize) -> Result<(), BoxErr> {
if got != expected {
return Err(format!("{kind} count mismatch: HDF5 has {got}, source has {expected}").into());
}
Ok(())
}
fn field_err<T: std::fmt::Display>(kind: &str, i: usize, field: &str, s: T, g: T) -> BoxErr {
format!("{kind}[{i}].{field} mismatch: source {s}, HDF5 {g}").into()
}
fn truncate(s: &str) -> String {
if s.len() <= 40 {
s.to_string()
} else {
format!("{}", &s[..40])
}
}
/// Indices of chunk rows to content-check. Full = all; otherwise a spread of
/// representative rows (first/last and evenly-spaced interior samples).
fn sample_indices(n: usize, full: bool) -> Vec<usize> {
if n == 0 {
return Vec::new();
}
if full || n <= 16 {
return (0..n).collect();
}
let mut idx: Vec<usize> = (0..16).map(|k| k * (n - 1) / 15).collect();
idx.dedup();
idx
}
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-netcdf4 # clawhdf5-netcdf4
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-netcdf4.svg)](https://crates.io/crates/rustyhdf5-netcdf4) [![crates.io](https://img.shields.io/crates/v/clawhdf5-netcdf4.svg)](https://crates.io/crates/clawhdf5-netcdf4)
[![docs.rs](https://docs.rs/rustyhdf5-netcdf4/badge.svg)](https://docs.rs/rustyhdf5-netcdf4) [![docs.rs](https://docs.rs/clawhdf5-netcdf4/badge.svg)](https://docs.rs/clawhdf5-netcdf4)
NetCDF-4 read support built on rustyhdf5 — pure Rust, no C dependencies. NetCDF-4 read support built on clawhdf5 — pure Rust, no C dependencies.
## Features ## Features
@@ -14,7 +14,7 @@ NetCDF-4 read support built on rustyhdf5 — pure Rust, no C dependencies.
## Usage ## Usage
```rust ```rust
use rustyhdf5_netcdf4::NetCDF4File; use clawhdf5_netcdf4::NetCDF4File;
let nc = NetCDF4File::open("climate.nc").unwrap(); let nc = NetCDF4File::open("climate.nc").unwrap();
let temp = nc.variable("temperature").unwrap(); let temp = nc.variable("temperature").unwrap();
+6 -6
View File
@@ -1,9 +1,9 @@
# rustyhdf5-py # clawhdf5-py
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-py.svg)](https://crates.io/crates/rustyhdf5-py) [![crates.io](https://img.shields.io/crates/v/clawhdf5-py.svg)](https://crates.io/crates/clawhdf5-py)
[![docs.rs](https://docs.rs/rustyhdf5-py/badge.svg)](https://docs.rs/rustyhdf5-py) [![docs.rs](https://docs.rs/clawhdf5-py/badge.svg)](https://docs.rs/clawhdf5-py)
Python bindings for rustyhdf5 — a pure-Rust HDF5 library. Python bindings for clawhdf5 — a pure-Rust HDF5 library.
## Features ## Features
@@ -14,9 +14,9 @@ Python bindings for rustyhdf5 — a pure-Rust HDF5 library.
## Usage ## Usage
```python ```python
import rustyhdf5 import clawhdf5
with rustyhdf5.File('data.h5', 'r') as f: with clawhdf5.File('data.h5', 'r') as f:
data = f['/dataset'][:] data = f['/dataset'][:]
``` ```
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-types # clawhdf5-types
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-types.svg)](https://crates.io/crates/rustyhdf5-types) [![crates.io](https://img.shields.io/crates/v/clawhdf5-types.svg)](https://crates.io/crates/clawhdf5-types)
[![docs.rs](https://docs.rs/rustyhdf5-types/badge.svg)](https://docs.rs/rustyhdf5-types) [![docs.rs](https://docs.rs/clawhdf5-types/badge.svg)](https://docs.rs/clawhdf5-types)
HDF5 type system definitions for the rustyhdf5 ecosystem. HDF5 type system definitions for the clawhdf5 ecosystem.
## Features ## Features
@@ -13,7 +13,7 @@ HDF5 type system definitions for the rustyhdf5 ecosystem.
## Usage ## Usage
```rust ```rust
use rustyhdf5_types::HDF5Type; use clawhdf5_types::HDF5Type;
``` ```
## License ## License
+4 -4
View File
@@ -1,7 +1,7 @@
# rustyhdf5 # clawhdf5
[![crates.io](https://img.shields.io/crates/v/rustyhdf5.svg)](https://crates.io/crates/rustyhdf5) [![crates.io](https://img.shields.io/crates/v/clawhdf5.svg)](https://crates.io/crates/clawhdf5)
[![docs.rs](https://docs.rs/rustyhdf5/badge.svg)](https://docs.rs/rustyhdf5) [![docs.rs](https://docs.rs/clawhdf5/badge.svg)](https://docs.rs/clawhdf5)
Pure-Rust HDF5 reader/writer — no C dependencies. Pure-Rust HDF5 reader/writer — no C dependencies.
@@ -16,7 +16,7 @@ Pure-Rust HDF5 reader/writer — no C dependencies.
## Usage ## Usage
```rust ```rust
use rustyhdf5::File; use clawhdf5::File;
let file = File::open("data.h5").unwrap(); let file = File::open("data.h5").unwrap();
let dataset = file.dataset("/group/data").unwrap(); let dataset = file.dataset("/group/data").unwrap();
+37 -2
View File
@@ -66,6 +66,9 @@ pub struct File {
superblock: Superblock, superblock: Superblock,
/// Per-file chunk cache shared across all dataset reads. /// Per-file chunk cache shared across all dataset reads.
chunk_cache: ChunkCache, chunk_cache: ChunkCache,
/// Directory the file was opened from, used to resolve external Virtual
/// Dataset source files relative to this file. `None` for in-memory files.
base_dir: Option<std::path::PathBuf>,
} }
impl File { impl File {
@@ -74,6 +77,7 @@ impl File {
/// When the `mmap` feature is enabled (default), this uses memory-mapped /// When the `mmap` feature is enabled (default), this uses memory-mapped
/// I/O. Otherwise it reads the entire file into a `Vec<u8>`. /// I/O. Otherwise it reads the entire file into a `Vec<u8>`.
pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> { pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
let base_dir = path.as_ref().parent().map(|p| p.to_path_buf());
#[cfg(feature = "mmap")] #[cfg(feature = "mmap")]
{ {
let reader = clawhdf5_io::MmapReader::open(path).map_err(Error::Io)?; let reader = clawhdf5_io::MmapReader::open(path).map_err(Error::Io)?;
@@ -84,12 +88,15 @@ impl File {
data: FileData::Mmap(reader), data: FileData::Mmap(reader),
superblock, superblock,
chunk_cache: ChunkCache::new(), chunk_cache: ChunkCache::new(),
base_dir,
}) })
} }
#[cfg(not(feature = "mmap"))] #[cfg(not(feature = "mmap"))]
{ {
let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?; let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
Self::from_bytes(bytes) let mut f = Self::from_bytes(bytes)?;
f.base_dir = base_dir;
Ok(f)
} }
} }
@@ -99,10 +106,15 @@ impl File {
/// undesirable (e.g. network filesystems, very small files, etc.). /// undesirable (e.g. network filesystems, very small files, etc.).
pub fn open_buffered<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> { pub fn open_buffered<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?; let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
Self::from_bytes(bytes) let mut f = Self::from_bytes(bytes)?;
f.base_dir = path.as_ref().parent().map(|p| p.to_path_buf());
Ok(f)
} }
/// Open an HDF5 file from an in-memory byte vector. /// Open an HDF5 file from an in-memory byte vector.
///
/// In-memory files have no directory, so external Virtual Dataset sources
/// cannot be resolved automatically (same-file VDS still works).
pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> { pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
let sig_offset = signature::find_signature(&data)?; let sig_offset = signature::find_signature(&data)?;
let superblock = Superblock::parse(&data, sig_offset)?; let superblock = Superblock::parse(&data, sig_offset)?;
@@ -110,6 +122,7 @@ impl File {
data: FileData::Owned(data), data: FileData::Owned(data),
superblock, superblock,
chunk_cache: ChunkCache::new(), chunk_cache: ChunkCache::new(),
base_dir: None,
}) })
} }
@@ -710,6 +723,28 @@ impl<'f> Dataset<'f> {
let ds = self.dataspace()?; let ds = self.dataspace()?;
let dl = self.data_layout()?; let dl = self.data_layout()?;
let pipeline = self.filter_pipeline(); let pipeline = self.filter_pipeline();
// Virtual datasets are assembled from source datasets; the per-file
// chunk cache does not apply. Route them through the resolver path so
// external sibling files resolve relative to this file's directory.
if matches!(dl, DataLayout::Virtual { .. }) {
let base_dir = self.file.base_dir.clone();
let resolver = move |name: &str| -> Option<Vec<u8>> {
let dir = base_dir.as_ref()?;
std::fs::read(dir.join(name)).ok()
};
return Ok(data_read::read_raw_data_full_with_resolver(
self.file.data.as_bytes(),
&dl,
&ds,
&dt,
pipeline.as_ref(),
self.file.offset_size(),
self.file.length_size(),
Some(&resolver),
)?);
}
Ok(data_read::read_raw_data_cached( Ok(data_read::read_raw_data_cached(
self.file.data.as_bytes(), self.file.data.as_bytes(),
&dl, &dl,
@@ -481,3 +481,77 @@ fn serde_json_minimal_parse(s: &str) -> Vec<f64> {
.map(|v| v.trim().parse::<f64>().unwrap()) .map(|v| v.trim().parse::<f64>().unwrap())
.collect() .collect()
} }
// ---------------------------------------------------------------------------
// A_dense. Write a group with many links (dense storage) -> h5py reads all
// ---------------------------------------------------------------------------
#[test]
fn clawhdf5_writes_dense_group_h5py_reads() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dense_group.h5");
let path_str = path.display().to_string();
// 20 links exceeds the compact threshold (8) -> dense fractal-heap storage.
let mut b = FileBuilder::new();
let mut g = b.create_group("big");
for i in 0..20 {
g.create_dataset(&format!("dataset_{i:03}"))
.with_i32_data(&[i, i * 10]);
}
b.add_group(g.finish());
b.write(&path).unwrap();
let script = format!(
r#"
import h5py
with h5py.File("{path_str}", "r") as f:
big = f["big"]
names = sorted(big.keys())
assert len(names) == 20, f"expected 20 links, got {{len(names)}}"
for i in range(20):
v = big[f"dataset_{{i:03}}"][()].tolist()
assert v == [i, i*10], f"link {{i}} = {{v}}"
print("OK")
"#
);
let out = run_python_output(&script);
assert_eq!(out, "OK");
}
// ---------------------------------------------------------------------------
// A_multiblock. Write a multi-direct-block fractal heap -> h5py reads
// ---------------------------------------------------------------------------
#[test]
fn clawhdf5_writes_multiblock_heap_h5py_reads() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("multiblock.h5");
let path_str = path.display().to_string();
// ~1600 dense attributes overflow a single 64KiB fractal-heap direct block.
let mut b = FileBuilder::new();
let mut g = b.create_group("g");
for i in 0..1600i64 {
g.set_attr(&format!("attribute_number_{i:05}"), AttrValue::I64(i * 2));
}
g.create_dataset("d").with_i32_data(&[1]);
b.add_group(g.finish());
b.write(&path).unwrap();
let script = format!(
r#"
import h5py
with h5py.File("{path_str}", "r") as f:
a = f["g"].attrs
assert len(a) == 1600, f"expected 1600 attrs, got {{len(a)}}"
for i in (0, 1, 999, 1599):
v = int(a[f"attribute_number_{{i:05}}"])
assert v == i*2, f"attr {{i}} = {{v}}"
print("OK")
"#
);
assert_eq!(run_python_output(&script), "OK");
}
+189
View File
@@ -706,6 +706,90 @@ fn fletcher32_roundtrip() {
// 15. Multiple groups with same-named datasets // 15. Multiple groups with same-named datasets
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[test]
fn multiple_chunked_datasets_share_file_cache() {
// The per-file ChunkCache is shared across datasets. Two chunked datasets
// of *different rank* must each read correctly: a 1-D dataset's chunk index
// (rank 1) must not be reused for a 2-D dataset (rank 2). Read the 1-D one
// first so it seeds the shared cache, then the 2-D one.
use clawhdf5_format::datatype::{CharacterSet, Datatype, StringPadding};
// 1-D chunked + compressed fixed-length strings (payload > compress threshold).
let strings: Vec<String> = (0..64).map(|i| format!("entry-{i:06}-{}", "x".repeat(80))).collect();
let max_len = strings.iter().map(|s| s.len()).max().unwrap();
let mut sraw = Vec::new();
for s in &strings {
let mut b = s.as_bytes().to_vec();
b.resize(max_len, 0);
sraw.extend_from_slice(&b);
}
let sdt = Datatype::String {
size: max_len as u32,
padding: StringPadding::NullPad,
charset: CharacterSet::Utf8,
};
// 2-D chunked + compressed f32 matrix.
let (n, d) = (40usize, 8usize);
let mat: Vec<f32> = (0..n * d).map(|i| i as f32).collect();
let mut b = FileBuilder::new();
{
let ds = b.create_dataset("strs");
ds.with_compound_data(sdt, sraw, strings.len() as u64);
ds.with_chunks(&[16]);
ds.with_deflate(6);
}
{
let ds = b.create_dataset("mat");
ds.with_f32_data(&mat).with_shape(&[n as u64, d as u64]);
ds.with_chunks(&[10, d as u64]).with_shuffle().with_deflate(6);
}
let bytes = b.finish().unwrap();
let file = File::from_bytes(bytes).unwrap();
// Read the 1-D dataset first (seeds the shared cache with a rank-1 index),
// then the 2-D dataset through the same File/cache.
let got_strs = file.dataset("strs").unwrap().read_string().unwrap();
assert_eq!(got_strs, strings);
let got_mat = file.dataset("mat").unwrap().read_f32().unwrap();
assert_eq!(got_mat, mat);
// Read the 1-D one again to confirm the cache rebinds back correctly.
assert_eq!(file.dataset("strs").unwrap().read_string().unwrap(), strings);
}
#[test]
fn virtual_dataset_external_file_auto_resolved() {
// The facade resolves external Virtual Dataset sources relative to the
// opened file's directory automatically. Drop both files side by side in a
// temp dir and open the virtual one through the public File API.
let virt = include_bytes!("../../clawhdf5-format/tests/fixtures/vds_external_virt.h5");
let src = include_bytes!("../../clawhdf5-format/tests/fixtures/vds_external_src.h5");
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("ext_src.h5"), src).unwrap();
let virt_path = dir.path().join("ext_virt.h5");
std::fs::write(&virt_path, virt).unwrap();
let file = File::open(&virt_path).unwrap();
let values = file.dataset("virt").unwrap().read_i32().unwrap();
assert_eq!(values, vec![10, 11, 12, 13, 14, 15, 16, 17]);
}
#[test]
fn virtual_dataset_external_missing_source_is_fill() {
// If the external source file is absent, its region reads as the zero fill
// value rather than erroring.
let virt = include_bytes!("../../clawhdf5-format/tests/fixtures/vds_external_virt.h5");
let dir = tempfile::tempdir().unwrap();
let virt_path = dir.path().join("ext_virt.h5");
std::fs::write(&virt_path, virt).unwrap();
let file = File::open(&virt_path).unwrap();
let values = file.dataset("virt").unwrap().read_i32().unwrap();
assert_eq!(values, vec![0; 8]);
}
#[test] #[test]
fn same_dataset_name_in_different_groups() { fn same_dataset_name_in_different_groups() {
let mut b = FileBuilder::new(); let mut b = FileBuilder::new();
@@ -730,3 +814,108 @@ fn same_dataset_name_in_different_groups() {
vec![3.0, 4.0] vec![3.0, 4.0]
); );
} }
#[test]
fn dense_group_links_roundtrip() {
// A group with more than the compact threshold (8) of links is stored
// densely (fractal heap + v2 B-tree). It must round-trip; a small sibling
// group stays compact. Names are chosen so hashes are non-trivial.
let mut b = FileBuilder::new();
let mut big = b.create_group("big");
for i in 0..20 {
big.create_dataset(&format!("dataset_{i:03}"))
.with_i32_data(&[i, i * 2, i * 3]);
}
b.add_group(big.finish());
let mut small = b.create_group("small");
small.create_dataset("a").with_f64_data(&[1.0]);
small.create_dataset("b").with_f64_data(&[2.0]);
b.add_group(small.finish());
let bytes = b.finish().unwrap();
let file = File::from_bytes(bytes).unwrap();
// All 20 dense-group links resolve, with correct data.
let mut names = file.group("big").unwrap().datasets().unwrap();
names.sort();
assert_eq!(names.len(), 20);
for i in 0..20 {
assert_eq!(
file.dataset(&format!("big/dataset_{i:03}"))
.unwrap()
.read_i32()
.unwrap(),
vec![i, i * 2, i * 3],
"dense link {i} mismatch"
);
}
// The small (compact) group still works.
assert_eq!(file.dataset("small/a").unwrap().read_f64().unwrap(), vec![1.0]);
assert_eq!(file.dataset("small/b").unwrap().read_f64().unwrap(), vec![2.0]);
}
#[test]
fn reads_libhdf5_multiblock_fractal_heap() {
// A group whose dense attributes overflow a single fractal-heap direct
// block, so libhdf5 stored them under a root indirect block (FHIB) with
// multiple direct blocks. Reading requires deriving the direct/indirect row
// split from the heap geometry, not the FRHP "starting rows" field.
let bytes = include_bytes!("../../clawhdf5-format/tests/fixtures/fractal_heap_multiblock.h5");
let file = File::from_bytes(bytes.to_vec()).unwrap();
let attrs = file.group("g").unwrap().attrs().unwrap();
for i in 0..80i64 {
let name = format!("a{i:03}");
match attrs.get(&name) {
Some(AttrValue::I64(v)) => assert_eq!(*v, i * 3, "{name}"),
other => panic!("{name} = {other:?}"),
}
}
}
#[test]
fn dense_attrs_multiblock_fractal_heap_roundtrip() {
// Enough dense attributes to overflow a single 64 KiB fractal-heap direct
// block, forcing a root indirect block over multiple direct blocks.
let mut b = FileBuilder::new();
let mut g = b.create_group("g");
let n = 1600i64;
for i in 0..n {
g.set_attr(&format!("attribute_number_{i:05}"), AttrValue::I64(i * 2));
}
g.create_dataset("d").with_i32_data(&[1]);
b.add_group(g.finish());
let file = File::from_bytes(b.finish().unwrap()).unwrap();
let attrs = file.group("g").unwrap().attrs().unwrap();
for i in 0..n {
match attrs.get(&format!("attribute_number_{i:05}")) {
Some(AttrValue::I64(v)) => assert_eq!(*v, i * 2, "attr {i}"),
other => panic!("attr {i} = {other:?}"),
}
}
}
#[test]
fn dense_links_multiblock_fractal_heap_roundtrip() {
// Enough links to overflow a single fractal-heap direct block.
let mut b = FileBuilder::new();
let mut g = b.create_group("big");
let n = 2200;
for i in 0..n {
g.create_dataset(&format!("dataset_number_{i:05}"))
.with_i32_data(&[i]);
}
b.add_group(g.finish());
let file = File::from_bytes(b.finish().unwrap()).unwrap();
assert_eq!(file.group("big").unwrap().datasets().unwrap().len(), n as usize);
for i in [0, 1, 1234, n - 1] {
assert_eq!(
file.dataset(&format!("big/dataset_number_{i:05}"))
.unwrap()
.read_i32()
.unwrap(),
vec![i]
);
}
}